symbiot 0.61.0 → 0.62.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/post.mjs CHANGED
@@ -33,7 +33,7 @@ import { resolveProvider, write } from "./ai.mjs";
33
33
  import { commits, discoveredRepos, laneMap } from "./scan.mjs";
34
34
  import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
35
35
  import { runHandoff, runningHandoff } from "./agents.mjs";
36
- import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames, draftStatuses, postedCmd } from "./marketing.mjs";
36
+ import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames, draftStatuses, postedCmd, parseDraft, inLane } from "./marketing.mjs";
37
37
 
38
38
  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") };
39
39
  const PLATFORM = "linkedin";
@@ -559,21 +559,38 @@ const CUSTOMER = [
559
559
  // first draft; week 1 is the first of them a post went out in (until one has, the one
560
560
  // you're in: a week with nothing posted doesn't count), or the day post-test.json's
561
561
  // start names, when you've moved it ({ "start": "2026-10-13" }). Each row: posts published, and how many
562
- // carried a picture or video; replies on LinkedIn (its watched notifications, marked
563
- // by watch.mjs markNews), the ones that may be a customer and those asking what it
562
+ // carried a picture or video (approved under Drafts to post, or gone out through Marketing:
563
+ // laneOut); replies on LinkedIn (its watched notifications, marked
564
+ // by watch.mjs markNews: comments, replies, mentions and messages, not reactions or a
565
+ // badge's bare count), the ones that may be a customer and those asking what it
564
566
  // costs; and npm installs of the packages your posts are about. Profile visits are
565
567
  // only in LinkedIn's own analytics. null before there's a first draft.
566
568
  const TEST_WEEKS = 4;
569
+ const isReply = (n) => !!n.social && (n.li === "message" || ["comment", "reply", "mention"].includes(n.type) || !!n.customer);
567
570
  const dayStart = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
568
571
  const addDays = (ts, n) => { const d = new Date(ts); d.setDate(d.getDate() + n); return d.getTime(); };
569
572
  const ymd = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
570
- function testPlan(paths = PATHS, now = Date.now()) {
573
+ // What went out through Marketing's lane: drafts its agent marked posted (when they went
574
+ // out), or approved for a time in the platform's scheduler that has come. Not one handed
575
+ // over from Drafts to post (post-<id>.md): the log has that one, from its approve. [{ ts, media }]
576
+ function laneOut(dir = MARKETING_DIR, now = Date.now()) {
577
+ const out = [];
578
+ for (const [rel, s] of Object.entries(draftStatuses(dir))) {
579
+ if (!s || /(^|\/)post-[0-9a-f]{8}\.md$/i.test(rel)) continue;
580
+ const ts = Number(s.posted) || (s.status === "approved" && s.scheduled ? Date.parse(String(s.scheduled).replace(" ", "T")) : NaN);
581
+ if (!Number.isFinite(ts) || ts > now) continue;
582
+ let media = 0; try { media = parseDraft(readFileSync(inLane(dir, rel), "utf8")).media.length; } catch {} // its file gone: still out
583
+ out.push({ ts, media });
584
+ }
585
+ return out;
586
+ }
587
+ function testPlan(paths = PATHS, now = Date.now(), lane = []) {
571
588
  const posts = loadPosts(paths).posts;
572
589
  let set = NaN; try { set = Date.parse(String(JSON.parse(readFileSync(paths.test, "utf8")).start || "").slice(0, 10) + "T00:00:00"); } catch {}
573
- const first = posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity);
590
+ const first = Math.min(posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity), ...lane.map((l) => l.ts));
574
591
  if (!set && first === Infinity) return null;
575
592
  let start = set ? dayStart(set) : dayStart(first);
576
- 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); }
593
+ if (!set) { const out = [...postLog(paths).filter((l) => l.action === "approved").map((l) => Number(l.ts)), ...lane.map((l) => l.ts)].sort((a, b) => a - b)[0], to = out || now; while (addDays(start, 7) <= to) start = addDays(start, 7); }
577
594
  return { start, weeks: Array.from({ length: TEST_WEEKS }, (_, i) => [addDays(start, 7 * i), addDays(start, 7 * (i + 1))]), posts };
578
595
  }
579
596
  // The npm packages your posts are about: the repos their git facts came from
@@ -584,12 +601,12 @@ function testPackages(posts, map = {}) {
584
601
  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 {} }
585
602
  return out.slice(0, 3);
586
603
  }
587
- function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {} } = {}) {
588
- const plan = testPlan(paths, now); if (!plan) return null;
589
- const log = postLog(paths);
604
+ function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {}, dir = MARKETING_DIR } = {}) {
605
+ const lane = laneOut(dir, now), plan = testPlan(paths, now, lane); if (!plan) return null;
606
+ const out = [...postLog(paths).filter((l) => l.action === "approved").map((l) => ({ ts: Number(l.ts), media: (l.media || []).length })), ...lane];
590
607
  const rows = plan.weeks.map(([from, to], i) => {
591
- 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));
592
- 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,
608
+ const inW = (t) => Number(t) >= from && Number(t) < to, pub = out.filter((o) => inW(o.ts)), rep = news.filter((n) => isReply(n) && inW(n.ts));
609
+ return { week: i + 1, from: ymd(from), to: ymd(addDays(to, -1)), started: now >= from, over: now >= to, published: pub.length, media: pub.filter((o) => o.media).length,
593
610
  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 };
594
611
  });
595
612
  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 };
@@ -613,5 +630,5 @@ async function testInstalls(t, { now = Date.now(), get = null } = {}) {
613
630
  }
614
631
  function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
615
632
 
616
- 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, handToMarketing, copyText, openUrl, maybeCustomer,
633
+ export { TEST_WEEKS, testWeeks, testInstalls, testPackages, laneOut, 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, handToMarketing, copyText, openUrl, maybeCustomer,
617
634
  mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
package/qr.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // symbiot — a QR code for pairing your phone (phone.mjs): byte mode, error
2
+ // correction level M, versions 1 to 20 (up to 666 bytes), with no dependency.
3
+ // qrMatrix(text) gives the modules (true is dark); qrSvg(text) an SVG of them with
4
+ // the quiet zone around, sized by the viewBox so the page scales it.
5
+ // ISO/IEC 18004: the layout tables below are its level M rows.
6
+
7
+ // [EC codewords per block, blocks in group 1, data codewords each, blocks in group 2, data codewords each]
8
+ const M_BLOCKS = [null,
9
+ [10, 1, 16, 0, 0], [16, 1, 28, 0, 0], [26, 1, 44, 0, 0], [18, 2, 32, 0, 0], [24, 2, 43, 0, 0],
10
+ [16, 4, 27, 0, 0], [18, 4, 31, 0, 0], [22, 2, 38, 2, 39], [22, 3, 36, 2, 37], [26, 4, 43, 1, 44],
11
+ [30, 1, 50, 4, 51], [22, 6, 36, 2, 37], [22, 8, 37, 1, 38], [24, 4, 40, 5, 41], [24, 5, 41, 5, 42],
12
+ [28, 7, 45, 3, 46], [28, 10, 46, 1, 47], [26, 9, 43, 4, 44], [26, 3, 44, 11, 45], [26, 3, 41, 13, 42]];
13
+ const ALIGN = [null, [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
14
+ [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90]];
15
+ const REMAINDER = (v) => (v >= 2 && v <= 6 ? 7 : v >= 14 && v <= 20 ? 3 : 0);
16
+ const MAX_VERSION = 20;
17
+ const dataCodewords = (v) => { const [, b1, d1, b2, d2] = M_BLOCKS[v]; return b1 * d1 + b2 * d2; };
18
+ const countBits = (v) => (v < 10 ? 8 : 16);
19
+
20
+ // ---- Reed-Solomon over GF(256), polynomial 0x11D --------------------------------
21
+ const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
22
+ for (let i = 0, x = 1; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
23
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
24
+ const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
25
+ function generator(n) {
26
+ let g = [1];
27
+ for (let i = 0; i < n; i++) { const next = new Array(g.length + 1).fill(0); g.forEach((c, j) => { next[j] ^= c; next[j + 1] ^= mul(c, EXP[i]); }); g = next; }
28
+ return g;
29
+ }
30
+ function ecFor(data, n) {
31
+ const g = generator(n), r = [...data, ...new Array(n).fill(0)];
32
+ for (let i = 0; i < data.length; i++) { const f = r[i]; if (f) for (let j = 0; j < g.length; j++) r[i + j] ^= mul(g[j], f); }
33
+ return r.slice(data.length);
34
+ }
35
+
36
+ // ---- the codewords ---------------------------------------------------------------
37
+ function versionFor(bytes) {
38
+ for (let v = 1; v <= MAX_VERSION; v++) if (4 + countBits(v) + bytes.length * 8 <= dataCodewords(v) * 8) return v;
39
+ return 0;
40
+ }
41
+ function codewords(bytes, v) {
42
+ const bits = [], put = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
43
+ put(4, 4); put(bytes.length, countBits(v)); for (const b of bytes) put(b, 8);
44
+ const cap = dataCodewords(v) * 8;
45
+ put(0, Math.min(4, cap - bits.length));
46
+ while (bits.length % 8) bits.push(0);
47
+ const data = [];
48
+ for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
49
+ for (let pad = 0; data.length < cap / 8; pad ^= 1) data.push(pad ? 0x11 : 0xec);
50
+ // split into blocks, each with its EC codewords; then interleave column by column
51
+ const [ec, b1, d1, b2, d2] = M_BLOCKS[v], blocks = [];
52
+ let at = 0;
53
+ for (let i = 0; i < b1 + b2; i++) { const n = i < b1 ? d1 : d2, d = data.slice(at, at + n); at += n; blocks.push({ d, e: ecFor(d, ec) }); }
54
+ const out = [], longest = Math.max(d1, d2);
55
+ for (let i = 0; i < longest; i++) for (const b of blocks) if (i < b.d.length) out.push(b.d[i]);
56
+ for (let i = 0; i < ec; i++) for (const b of blocks) out.push(b.e[i]);
57
+ return out;
58
+ }
59
+
60
+ // ---- the matrix ------------------------------------------------------------------
61
+ // Function patterns (finders, timing, alignment, format and version areas) are
62
+ // marked in `fixed`, so the data and the masks go around them.
63
+ function frame(v) {
64
+ const n = 17 + 4 * v, m = Array.from({ length: n }, () => new Array(n).fill(false)), fixed = Array.from({ length: n }, () => new Array(n).fill(false));
65
+ const set = (r, c, dark) => { if (r >= 0 && r < n && c >= 0 && c < n) { m[r][c] = dark; fixed[r][c] = true; } };
66
+ for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]])
67
+ for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
68
+ const ring = Math.max(Math.abs(r - 3), Math.abs(c - 3));
69
+ set(r0 + r, c0 + c, ring !== 2 && ring !== 4 && r >= 0 && r <= 6 && c >= 0 && c <= 6);
70
+ }
71
+ for (let i = 8; i < n - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
72
+ const pos = ALIGN[v];
73
+ for (const r of pos) for (const c of pos) {
74
+ if ((r === 6 && c === 6) || (r === 6 && c === n - 7) || (r === n - 7 && c === 6)) continue;
75
+ for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
76
+ }
77
+ for (let i = 0; i < 9; i++) { if (!fixed[8][i]) set(8, i, false); if (!fixed[i][8]) set(i, 8, false); }
78
+ for (let i = 0; i < 8; i++) { set(8, n - 1 - i, false); set(n - 1 - i, 8, false); }
79
+ set(n - 8, 8, true); // the dark module, beside the bottom-left format bits
80
+ if (v >= 7) for (let i = 0; i < 6; i++) for (let j = 0; j < 3; j++) { set(i, n - 11 + j, false); set(n - 11 + j, i, false); }
81
+ return { n, m, fixed };
82
+ }
83
+ // The data modules in placement order: two columns at a time from the right,
84
+ // up then down, skipping the vertical timing column.
85
+ function dataOrder(n, fixed) {
86
+ const out = [];
87
+ for (let right = n - 1, up = true; right > 0; right -= 2, up = !up) {
88
+ if (right === 6) right = 5;
89
+ for (let i = 0; i < n; i++) { const r = up ? n - 1 - i : i; for (const c of [right, right - 1]) if (!fixed[r][c]) out.push([r, c]); }
90
+ }
91
+ return out;
92
+ }
93
+ const MASKS = [(r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
94
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
95
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0];
96
+ const bch = (val, gen, genBits) => { let v = val << (genBits - 1); for (let i = 31 - Math.clz32(v); i >= genBits - 1; i--) if ((v >>> i) & 1) v ^= gen << (i - genBits + 1); return v; };
97
+ // level M is 00; the 15 bits go around the top-left finder and split between the other two
98
+ function formatBits(mask) { const d = (0 << 3) | mask; return ((d << 10) | bch(d, 0x537, 11)) ^ 0x5412; }
99
+ function placeFormat(m, n, mask) {
100
+ const f = formatBits(mask), bit = (i) => ((f >>> i) & 1) === 1;
101
+ for (let i = 0; i < 15; i++) {
102
+ const b = bit(i);
103
+ // around the top-left finder
104
+ if (i < 6) m[i][8] = b; else if (i < 8) m[i + 1][8] = b; else if (i === 8) m[8][7] = b; else m[8][14 - i] = b;
105
+ // the copy, split between the top-right and bottom-left finders
106
+ if (i < 8) m[8][n - 1 - i] = b; else m[n - 15 + i][8] = b;
107
+ }
108
+ }
109
+ function placeVersion(m, n, v) {
110
+ if (v < 7) return;
111
+ const bits = (v << 12) | bch(v, 0x1f25, 13);
112
+ for (let i = 0; i < 18; i++) { const b = ((bits >>> i) & 1) === 1, r = Math.floor(i / 3), c = n - 11 + (i % 3); m[r][c] = b; m[c][r] = b; }
113
+ }
114
+ function penalty(m, n) {
115
+ let p = 0;
116
+ for (let pass = 0; pass < 2; pass++) for (let i = 0; i < n; i++) {
117
+ let run = 1;
118
+ for (let j = 1; j <= n; j++) {
119
+ const a = j < n ? (pass ? m[j][i] : m[i][j]) : null, b = pass ? m[j - 1][i] : m[i][j - 1];
120
+ if (a === b) run++; else { if (run >= 5) p += run - 2; run = 1; }
121
+ }
122
+ }
123
+ for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) { const v = m[r][c]; if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) p += 3; }
124
+ const pat = [true, false, true, true, true, false, true], at = (r, c, horiz) => (horiz ? m[r][c] : m[c][r]);
125
+ for (let pass = 0; pass < 2; pass++) for (let i = 0; i < n; i++) for (let j = 0; j + 7 <= n; j++) {
126
+ if (!pat.every((x, k) => at(i, j + k, !pass) === x)) continue;
127
+ const light = (from) => { for (let k = from; k < from + 4; k++) if (k >= 0 && k < n && at(i, k, !pass)) return false; return true; };
128
+ if (light(j - 4)) p += 40;
129
+ if (light(j + 7)) p += 40;
130
+ }
131
+ let dark = 0; for (const row of m) for (const x of row) if (x) dark++;
132
+ p += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
133
+ return p;
134
+ }
135
+ function qrMatrix(text, { mask = -1 } = {}) {
136
+ const bytes = [...Buffer.from(String(text), "utf8")], v = versionFor(bytes);
137
+ if (!v) throw new Error("too long for a QR code here");
138
+ const words = codewords(bytes, v), { n, m, fixed } = frame(v), order = dataOrder(n, fixed);
139
+ const bits = []; for (const w of words) for (let i = 7; i >= 0; i--) bits.push((w >>> i) & 1);
140
+ for (let i = 0; i < REMAINDER(v); i++) bits.push(0);
141
+ let best = null;
142
+ for (let k = 0; k < 8; k++) {
143
+ if (mask >= 0 && k !== mask) continue;
144
+ const t = m.map((row) => row.slice());
145
+ order.forEach(([r, c], i) => { t[r][c] = (bits[i] === 1) !== MASKS[k](r, c); });
146
+ placeFormat(t, n, k); placeVersion(t, n, v);
147
+ const score = mask >= 0 ? 0 : penalty(t, n);
148
+ if (!best || score < best.score) best = { score, t, k };
149
+ }
150
+ return { version: v, mask: best.k, size: n, modules: best.t };
151
+ }
152
+ // An SVG of it, black on white with the 4-module quiet zone a camera needs.
153
+ function qrSvg(text) {
154
+ const { size, modules } = qrMatrix(text), q = 4, all = size + 2 * q;
155
+ let d = "";
156
+ modules.forEach((row, r) => row.forEach((dark, c) => { if (dark) d += `M${c + q} ${r + q}h1v1h-1z`; }));
157
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${all} ${all}" shape-rendering="crispEdges"><rect width="${all}" height="${all}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
158
+ }
159
+
160
+ export { qrMatrix, qrSvg, M_BLOCKS, ALIGN, REMAINDER, frame, dataOrder, MASKS, countBits };
package/server.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // `symbiot app`: the local server behind the app window. It serves the page
2
2
  // (ui.mjs) and its /api routes, each a thin call into the module that does the
3
- // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
3
+ // work, on 127.0.0.1 only and behind the per-install token. (Your phone's link
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
5
  import { languageState, setLanguage, translate, allTranslations } from "./lang.mjs";
6
6
  import { licenceState, setKey, clearKey, can, refreshRevoked, FREE } from "./licence.mjs";
@@ -25,20 +25,20 @@ 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 } from "./headless.mjs";
28
+ import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, 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 } from "./links.mjs";
31
+ import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked } 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
34
  import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
35
35
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
36
36
  import { adaptState, noteUse } from "./adapt.mjs";
37
- import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
37
+ import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding, startOnboarding } from "./home.mjs";
38
38
  import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
39
39
  import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
40
40
  import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
41
- import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
41
+ import { phoneState, setPhoneLink, newCode, unpairPhone, setRelay, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, setPhoneApprove, startPhone } from "./phone.mjs";
42
42
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
43
43
  import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
44
44
 
@@ -240,7 +240,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
240
240
  // an installed Symbiot keeps its app-menu entry pointing at itself (a new Node, a moved
241
241
  // install); not a copy run from a checkout or a test
242
242
  if (/[\\/]node_modules[\\/]symbiot[\\/]/.test(bin || "") && !SANDBOX && !process.env.SYMBIOT_NO_LAUNCHER) { try { installLauncher({ script: bin }); } catch {} }
243
- if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
243
+ // setup's first step: "Your computer" in the Android app, so a phone paired by its QR shows it
244
+ if (FRESH) { try { startOnboarding(); } catch {} }
244
245
  // your projects: kept, and found again off the main thread, so nothing waits on a search
245
246
  setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
246
247
  if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
@@ -382,8 +383,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
382
383
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
383
384
  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)
384
385
  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
385
- 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)" }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
386
+ 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)) : "")); }
386
387
  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(); setTimeout(() => { for (const it of linksState().items) if (it.state !== "off" && it.state !== "ok") checkLink(it.id).catch(() => {}); }, 1500); return json(res, { ...r, checking: true }); }
388
+ // 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
389
+ 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)); }
387
390
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
388
391
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
389
392
  // Posts (post.mjs): the week's drafts, waiting on you. Approve is your yes: Marketing's
@@ -459,7 +462,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
459
462
  try { const wav = await speak(b.text, b.lang || ""); res.writeHead(200, { "content-type": "audio/wav", "cache-control": "no-store", "content-length": wav.length }); return res.end(wav); }
460
463
  catch (e) { return json(res, { error: String((e && e.message) || e) }); }
461
464
  }
462
- 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) })); }
465
+ if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); const r = await homeAsk(b.question, { images: saveShots(b.images) }); let si = null; try { si = r && r.answer ? signInAsked(r.answer) : null; } catch {} return json(res, si ? { ...r, signInTo: si } : r); } // a reply asking you to sign in carries the button
463
466
  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" } : {}) }));
464
467
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
465
468
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
@@ -484,15 +487,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
484
487
  // a Dashboard card's chat: go over what's new with your AI before a reply is drafted
485
488
  if (u.pathname === "/api/watch/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await boardChat(String(b.id || ""), b.question)); }
486
489
  if (u.pathname === "/api/watch/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearBoardChat(String(b.id || ""))); }
487
- // Watch on your phone (phone.mjs). On the computer: listen on your network
488
- // for the phone (only on your click), a pairing code, the phones paired.
489
- // On the phone: pair with the computer, ask it now, forget it.
490
+ // Your phone (phone.mjs). On the computer: listen on your network for the phone
491
+ // (only on your click), a pairing code and its QR, the phones paired, the relay.
492
+ // On the phone: pair with the computer (typed, or a scanned link), ask it now
493
+ // (also when the phone's network changes), the copy of your work there, a change
494
+ // made on the phone (queued until the computer answers), forget it.
490
495
  if (u.pathname === "/api/phone") return json(res, phoneState());
491
496
  if (u.pathname === "/api/phone/link" && req.method === "POST") { const b = await readBody(req); return json(res, await setPhoneLink(b.on === true)); }
492
497
  if (u.pathname === "/api/phone/code" && req.method === "POST") return json(res, newCode());
493
498
  if (u.pathname === "/api/phone/unpair" && req.method === "POST") { const b = await readBody(req); return json(res, unpairPhone(String(b.id || ""))); }
494
- if (u.pathname === "/api/phone/pair" && req.method === "POST") { const b = await readBody(req); return json(res, await pairComputer(b.address, b.code)); }
499
+ if (u.pathname === "/api/phone/relay" && req.method === "POST") { const b = await readBody(req); return json(res, setRelay(b.on === true)); }
500
+ if (u.pathname === "/api/phone/pair" && req.method === "POST") { const b = await readBody(req); return json(res, await pairComputer(b.address, b.code, { link: String(b.link || "") })); }
495
501
  if (u.pathname === "/api/phone/check" && req.method === "POST") return json(res, await pollComputer());
502
+ if (u.pathname === "/api/phone/find") return json(res, { found: await findComputers() });
503
+ if (u.pathname === "/api/phone/do" && req.method === "POST") { const b = await readBody(req); return json(res, queueChange(String(b.op || ""), b.args || {})); }
504
+ if (u.pathname === "/api/phone/note" && req.method === "POST") { const b = await readBody(req); return json(res, dismissNote(String(b.id || ""))); }
496
505
  if (u.pathname === "/api/phone/forget" && req.method === "POST") return json(res, forgetComputer());
497
506
  // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
498
507
  if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
@@ -567,6 +576,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
567
576
  server.on("listening", () => {
568
577
  if (announced) return; announced = true;
569
578
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
579
+ setBrowserHub(`http://127.0.0.1:${server.address().port}/browser?t=${TOKEN}`); // the Symbiot Browser opens on its own page, served from here
570
580
  const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
571
581
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
572
582
  console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in ${/^[aeiou]/.test(how) ? "an" : "a"} ${how}.`) : c.d(" Open that URL in your browser."));
@@ -596,7 +606,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
596
606
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
597
607
  startWeekly(writeup); // the weekly write-up + notification, when switched on in Settings
598
608
  startWatches(); // pages you watch (Screens → Watch), read every few minutes
599
- startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
609
+ // Your phone: the computer listens if it's switched on, the phone asks if it's paired.
610
+ // An Approve from a paired phone is this same Approve, release notes and all.
611
+ setPhoneApprove(async (repo, untasked) => { const notes = await releaseNotes(releaseInput(repo, {})); return untasked ? approveChanges(repo, { tick: [], notes }) : approveRepo(repo, { notes }); });
612
+ startPhone();
600
613
  setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
601
614
  setInterval(() => { try { workTick(); } catch {} }, 20000).unref(); // a waiting task starts once its lane is free (config.autoStart: false leaves it to Go)
602
615
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
package/tasks.mjs CHANGED
@@ -50,7 +50,17 @@ function addTask(text, repo, { after = "" } = {}) {
50
50
  const item = { id, text: line, ...(long ? { full } : {}), repo: repo || "", done: false, ts: Date.now() };
51
51
  t.unshift(item); saveTasks(t); return item;
52
52
  }
53
- function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
53
+ function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; it.changed = Date.now(); saveTasks(t); } return it || { error: "not found" }; }
54
+ // Done or not, as said (not flipped), for a change that can arrive late or twice
55
+ // (your phone's, phone.mjs): at is when it was made, and a tick here since wins.
56
+ function setTaskDone(id, done, { at = Date.now() } = {}) {
57
+ const t = loadTasks(), it = t.find((x) => x.id === id);
58
+ if (!it || it.archived) return { error: "not found" };
59
+ if (it.done === !!done) return it;
60
+ if ((Number(it.changed) || 0) > at) return { conflict: true, ...it };
61
+ it.done = !!done; it.changed = at; saveTasks(t);
62
+ return it;
63
+ }
54
64
  function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
55
65
  function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; if (it.removedBy || it.dropped) { it.kept = true; delete it.removedBy; delete it.merged; delete it.dropped; } saveTasks(t); } return it || { error: "not found" }; }
56
66
  // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
@@ -700,4 +710,4 @@ function pushTasks(filter) {
700
710
  return { empty: false, written, unresolved, handoff: handoffCmd() };
701
711
  }
702
712
 
703
- export { gitFailed, taskPaths, addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
713
+ export { gitFailed, taskPaths, addTask, toggleTask, setTaskDone, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };