symbiot 0.54.3 → 0.55.0
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 +17 -0
- package/README.md +32 -7
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/away.mjs +107 -0
- package/handover.mjs +41 -2
- package/home.mjs +48 -14
- package/index.mjs +18 -1
- package/package.json +4 -3
- package/post.mjs +68 -10
- package/server.mjs +15 -4
- package/tasks.mjs +11 -2
- package/ui.mjs +174 -44
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,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
//
|
|
22
22
|
// Stored in ~/.config/symbiot/posts.json, readable by you only:
|
|
23
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 (
|
|
24
|
+
// status: waiting (under Marketing), approved, skipped or replaced (a newer draft took its place).
|
|
25
25
|
import { join, dirname } from "node:path";
|
|
26
26
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync, readdirSync, rmSync, readlinkSync } from "node:fs";
|
|
27
27
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -32,7 +32,7 @@ import { resolveProvider, write } from "./ai.mjs";
|
|
|
32
32
|
import { commits, discoveredRepos } from "./scan.mjs";
|
|
33
33
|
import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
|
|
34
34
|
|
|
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") };
|
|
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") };
|
|
36
36
|
const PLATFORM = "linkedin";
|
|
37
37
|
// LinkedIn's share box, opened for you to paste an approved post into.
|
|
38
38
|
const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
|
|
@@ -42,7 +42,7 @@ const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 chara
|
|
|
42
42
|
const q = (s) => JSON.stringify(String(s));
|
|
43
43
|
|
|
44
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.";
|
|
45
|
-
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.`;
|
|
46
46
|
|
|
47
47
|
// ---- your voice ------------------------------------------------------------------
|
|
48
48
|
// voice.md's examples: what's between lines of ---, each long enough to be a post.
|
|
@@ -62,7 +62,7 @@ const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").
|
|
|
62
62
|
async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
|
|
63
63
|
const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
|
|
64
64
|
if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
|
|
65
|
-
if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn
|
|
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." };
|
|
66
66
|
if (r.error) return { error: r.error };
|
|
67
67
|
const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
|
|
68
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.` };
|
|
@@ -200,7 +200,7 @@ const SYSTEM = `You draft LinkedIn posts for a developer, from their real work t
|
|
|
200
200
|
`Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","show":"…","facts":[1,4]},{"kind":"learned","text":"…","show":"…","facts":[…]},{"kind":"long","text":"…","show":"…","facts":[…]}]}, ` +
|
|
201
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
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
|
|
203
|
+
// Draft this week's 3 posts and put them under Marketing (waiting on you). With
|
|
204
204
|
// no AI connected it says so and does nothing else: nothing is read, written or
|
|
205
205
|
// logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
|
|
206
206
|
// and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
|
|
@@ -265,9 +265,10 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
|
|
|
265
265
|
} catch { return false; }
|
|
266
266
|
}
|
|
267
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 []; } }
|
|
268
|
-
// What
|
|
268
|
+
// What Marketing shows: the drafts waiting on you, the last few you dealt with,
|
|
269
269
|
// and whether it can draft: an AI connected, and your voice (examples in voice.md,
|
|
270
|
-
// 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).
|
|
271
272
|
function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
|
|
272
273
|
const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
|
|
273
274
|
let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
|
|
@@ -520,14 +521,71 @@ function openUrl(url) {
|
|
|
520
521
|
|
|
521
522
|
// ---- replies: who might be a customer ------------------------------------------------
|
|
522
523
|
// A comment or mention that asks how to install it, what it costs or about its
|
|
523
|
-
// 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.
|
|
524
525
|
// Gives why ("install", "pricing", "team") or "".
|
|
525
526
|
const CUSTOMER = [
|
|
526
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],
|
|
527
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],
|
|
528
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],
|
|
529
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
|
+
}
|
|
530
588
|
function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
|
|
531
589
|
|
|
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,
|
|
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,
|
|
533
591
|
mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
|
package/server.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
|
|
4
4
|
// listens on your network separately, only for that: phone.mjs.)
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { toggleAway, closeAway } from "./away.mjs";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,7 +14,7 @@ import { createServer } from "node:http";
|
|
|
13
14
|
import { randomBytes } from "node:crypto";
|
|
14
15
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
15
16
|
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";
|
|
17
|
+
import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
|
|
17
18
|
import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
18
19
|
import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
|
|
19
20
|
import { computeDrift } from "./drift.mjs";
|
|
@@ -22,9 +23,9 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
|
|
|
22
23
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
23
24
|
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
24
25
|
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";
|
|
26
|
+
import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
26
27
|
import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
|
|
27
|
-
import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
28
|
+
import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
28
29
|
import { mindState, forget } from "./mind.mjs";
|
|
29
30
|
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
30
31
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
@@ -213,6 +214,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
213
214
|
const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
214
215
|
const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
|
|
215
216
|
let UPDATING = null; // an update in flight: { target, attempt, retrying? }
|
|
217
|
+
process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
|
|
216
218
|
const server = createServer(async (req, res) => {
|
|
217
219
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
218
220
|
if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
|
|
@@ -332,13 +334,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
332
334
|
if (u.pathname === "/api/mind") return json(res, mindState());
|
|
333
335
|
if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
|
|
334
336
|
// Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
|
|
335
|
-
if (u.pathname === "/api/links") return json(res, linksState());
|
|
337
|
+
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)
|
|
336
338
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
|
|
337
339
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
338
340
|
if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
|
|
339
341
|
// Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
|
|
340
342
|
// the page copies the post itself and opens LinkedIn's share box; nothing is posted.
|
|
341
343
|
if (u.pathname === "/api/posts") return json(res, postsState());
|
|
344
|
+
// Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
|
|
345
|
+
// and the 4-week test's table, with npm installs of the packages the posts are about
|
|
346
|
+
if (u.pathname === "/api/marketing") {
|
|
347
|
+
const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
|
|
348
|
+
let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
|
|
349
|
+
if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
|
|
350
|
+
return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
|
|
351
|
+
}
|
|
342
352
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
343
353
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
|
|
344
354
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -363,6 +373,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
363
373
|
if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
|
|
364
374
|
if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
|
|
365
375
|
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 })); }
|
|
376
|
+
if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
|
|
366
377
|
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) })); }
|
|
367
378
|
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" } : {}) }));
|
|
368
379
|
if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
|
package/tasks.mjs
CHANGED
|
@@ -433,7 +433,10 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
433
433
|
const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
|
|
434
434
|
const newBranch = () => {
|
|
435
435
|
const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
|
|
436
|
-
|
|
436
|
+
// a name free here and on origin: an earlier day's "7 tasks" may be merged and gone
|
|
437
|
+
// locally but still there, and pushing onto it is refused
|
|
438
|
+
const taken = (b) => git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok || git(path, ["rev-parse", "--verify", "-q", "refs/remotes/origin/" + b]).ok;
|
|
439
|
+
let b = stem; for (let i = 2; taken(b); i++) b = `${stem}-${i}`;
|
|
437
440
|
return b;
|
|
438
441
|
};
|
|
439
442
|
if (branch === base) {
|
|
@@ -462,7 +465,13 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
462
465
|
const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
|
|
463
466
|
if (opts.push === false) return out;
|
|
464
467
|
if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
|
|
465
|
-
|
|
468
|
+
let ps = git(path, ["push", "-u", "origin", branch], 120000);
|
|
469
|
+
// refused because origin's branch of that name went another way (an earlier PR of
|
|
470
|
+
// that name, merged): the commit moves to a fresh name and goes up on that
|
|
471
|
+
if (!ps.ok && /rejected|non-fast-forward|fetch first/i.test(ps.err)) {
|
|
472
|
+
const next = newBranch();
|
|
473
|
+
if (git(path, ["branch", "-m", branch, next]).ok) { branch = next; out.branch = next; ps = git(path, ["push", "-u", "origin", branch], 120000); }
|
|
474
|
+
}
|
|
466
475
|
if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
|
|
467
476
|
out.pushed = true;
|
|
468
477
|
if (opts.pr === false) return out;
|