symbiot 0.50.2 → 0.51.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 +133 -2
- package/adapt.mjs +1 -1
- package/agents.mjs +65 -16
- package/core.mjs +26 -2
- package/handback.mjs +178 -0
- package/handover.mjs +33 -3
- package/headless.mjs +29 -4
- package/home.mjs +15 -4
- package/index.mjs +192 -3
- package/knowledge.mjs +361 -0
- package/lanes.mjs +9 -8
- package/links.mjs +4 -1
- package/mail.mjs +27 -1
- package/mind.mjs +14 -9
- package/package.json +7 -2
- package/post.mjs +317 -0
- package/reports.mjs +136 -0
- package/sandbox.mjs +55 -0
- package/server.mjs +37 -2
- package/tasks.mjs +54 -20
- package/ui.mjs +163 -23
- package/watch.mjs +113 -20
package/post.mjs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// symbiot — Post: your week's real work as three draft posts (LinkedIn), in your
|
|
2
|
+
// own voice, each waiting on you on the Dashboard: Approve, Edit or Skip.
|
|
3
|
+
//
|
|
4
|
+
// - What it writes from: the last 7 days of commits, the release tags (and gh's
|
|
5
|
+
// releases, when gh works) and the CHANGELOG.md sections dated in that window,
|
|
6
|
+
// in the repos Symbiot already scans. Each is a numbered fact; the AI must cite
|
|
7
|
+
// the facts each post uses, and a post that names a number, a version or a name
|
|
8
|
+
// the facts don't show is sent back once, then dropped (checkClaims).
|
|
9
|
+
// - Your voice: example posts of yours in voice.md (Symbiot's config folder), a
|
|
10
|
+
// line of --- between each. Without them it doesn't draft. Link LinkedIn and it
|
|
11
|
+
// can read your recent posts into voice.md for you, on your click.
|
|
12
|
+
// - Nothing is posted by Symbiot, ever. LinkedIn has no posting route without a
|
|
13
|
+
// partner app, so Approve copies the post to your clipboard and opens LinkedIn's
|
|
14
|
+
// share box for you to paste it. It doesn't schedule.
|
|
15
|
+
// - Every action (drafted, edited, approved, skipped, dropped, replaced) is
|
|
16
|
+
// appended to posts-log.jsonl, with the text, the date and the platform.
|
|
17
|
+
//
|
|
18
|
+
// Stored in ~/.config/symbiot/posts.json, readable by you only:
|
|
19
|
+
// { posts: [{ id, kind, text, facts: [n], sources: [fact line], platform, status, drafted, edited?, approved?, skipped? }] }
|
|
20
|
+
// status: waiting (on the Dashboard), approved, skipped or replaced (a newer draft took its place).
|
|
21
|
+
import { join, dirname } from "node:path";
|
|
22
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
23
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { CONFIG_DIR, sh, hasCmd } from "./core.mjs";
|
|
26
|
+
import { resolveProvider, write } from "./ai.mjs";
|
|
27
|
+
import { commits, discoveredRepos } from "./scan.mjs";
|
|
28
|
+
import { readTexts } from "./headless.mjs";
|
|
29
|
+
|
|
30
|
+
const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md") };
|
|
31
|
+
const PLATFORM = "linkedin";
|
|
32
|
+
// LinkedIn's share box, opened for you to paste an approved post into.
|
|
33
|
+
const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
|
|
34
|
+
const KINDS = ["shipped", "learned", "long"];
|
|
35
|
+
const KIND_LABEL = { shipped: "Shipped", learned: "Learned / fixed", long: "Longer post" };
|
|
36
|
+
const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 characters a post
|
|
37
|
+
const q = (s) => JSON.stringify(String(s));
|
|
38
|
+
|
|
39
|
+
const NO_AI = "Symbiot needs an AI to draft your posts, and none is connected. Connect one with symbiot login (or Settings in the app), then run symbiot post again. Nothing was drafted, saved or logged.";
|
|
40
|
+
const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Dashboard → Links → LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
|
|
41
|
+
|
|
42
|
+
// ---- your voice ------------------------------------------------------------------
|
|
43
|
+
// voice.md's examples: what's between lines of ---, each long enough to be a post.
|
|
44
|
+
function voiceOf(text) { return String(text || "").split(/^\s*-{3,}\s*$/m).map((s) => s.trim()).filter((s) => s.length >= 20); }
|
|
45
|
+
function loadVoice(file = PATHS.voice) { try { return voiceOf(readFileSync(file, "utf8")); } catch { return []; } }
|
|
46
|
+
function saveText(file, text) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text, { mode: 0o600 }); try { chmodSync(file, 0o600); } catch {} }
|
|
47
|
+
|
|
48
|
+
// Your recent posts on LinkedIn, read in Symbiot's signed-in hidden browser (Link
|
|
49
|
+
// LinkedIn first), added to voice.md. Only on your click or command: nothing here
|
|
50
|
+
// runs by itself. `read` is headless.mjs readTexts (the tests pass their own).
|
|
51
|
+
const LINKEDIN_ACTIVITY = "https://www.linkedin.com/in/me/recent-activity/shares/";
|
|
52
|
+
const LINKEDIN_POST_TEXT = ".update-components-text, .feed-shared-update-v2__description, .feed-shared-text";
|
|
53
|
+
const tidyPost = (t) => String(t || "").replace(/\s*…\s*(see )?more\s*$/i, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
54
|
+
async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
|
|
55
|
+
const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
|
|
56
|
+
if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
|
|
57
|
+
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 on the Dashboard (Links → LinkedIn), sign in in the window that opens, close it, then try again." };
|
|
58
|
+
if (r.error) return { error: r.error };
|
|
59
|
+
const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
|
|
60
|
+
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.` };
|
|
61
|
+
const had = loadVoice(paths.voice), add = found.filter((t) => !had.includes(t));
|
|
62
|
+
const all = [...had, ...add].slice(0, Math.max(max, had.length));
|
|
63
|
+
saveText(paths.voice, all.join("\n\n---\n\n") + "\n");
|
|
64
|
+
return { ok: true, added: all.length - had.length, total: all.length, file: paths.voice };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- the facts: what git shows for the week ---------------------------------------
|
|
68
|
+
// Release tags made in the window: [{ tag, date }].
|
|
69
|
+
function tagsIn(path, since) {
|
|
70
|
+
return sh(`git -C ${q(path)} for-each-ref refs/tags --sort=-creatordate --count=12 --format='%(refname:short)|%(creatordate:short)' 2>/dev/null`)
|
|
71
|
+
.split("\n").map((l) => l.split("|")).filter((x) => x[0] && x[1] && x[1] >= since).map(([tag, date]) => ({ tag, date }));
|
|
72
|
+
}
|
|
73
|
+
// gh's releases in the window, when gh is there and signed in: [{ tag, name, date }].
|
|
74
|
+
function ghReleasesIn(path, since) {
|
|
75
|
+
if (!/github\.com/.test(sh(`git -C ${q(path)} remote get-url origin 2>/dev/null`))) return [];
|
|
76
|
+
try { return JSON.parse(sh(`cd ${q(path)} && gh release list --limit 10 --json tagName,name,publishedAt 2>/dev/null`) || "[]").map((r) => ({ tag: r.tagName, name: r.name || "", date: String(r.publishedAt || "").slice(0, 10) })).filter((r) => r.tag && r.date >= since); } catch { return []; }
|
|
77
|
+
}
|
|
78
|
+
// CHANGELOG.md's sections whose heading has a date in the window, each bullet a
|
|
79
|
+
// line: [{ version, date, text }]. "## 0.46.1 — 2026-10-06" or "## [1.2.0] - 2026-10-01".
|
|
80
|
+
function changelogIn(path, since) {
|
|
81
|
+
const file = ["CHANGELOG.md", "changelog.md", "CHANGES.md"].map((f) => join(path, f)).find((f) => existsSync(f)); if (!file) return [];
|
|
82
|
+
let text = ""; try { text = readFileSync(file, "utf8"); } catch { return []; }
|
|
83
|
+
const out = []; let cur = null;
|
|
84
|
+
for (const line of text.split("\n")) {
|
|
85
|
+
const h = line.match(/^##\s+(.*)$/); // a release; its ### Added / Fixed stay in it
|
|
86
|
+
if (h) { const d = h[1].match(/\b(\d{4}-\d{2}-\d{2})\b/), v = h[1].match(/\bv?(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)\b/); cur = d && d[1] >= since ? { version: v ? v[1] : "", date: d[1] } : null; continue; }
|
|
87
|
+
const b = cur && line.match(/^\s*[-*]\s+(.+)/);
|
|
88
|
+
if (b) out.push({ ...cur, text: b[1].replace(/\*\*(.+?)\*\*/g, "$1").trim().slice(0, 400) });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
// Everything the week shows, as facts: releases first, then the changelog, then
|
|
93
|
+
// commits (yours; everyone's if none are yours). [{ repo, date, kind, text }]
|
|
94
|
+
function gatherFacts({ days = 7, now = Date.now(), repos, gh } = {}) {
|
|
95
|
+
const sinceIso = new Date(now - days * 86400000).toISOString(), since = sinceIso.slice(0, 10);
|
|
96
|
+
const useGh = gh === undefined ? hasCmd("gh") : !!gh;
|
|
97
|
+
const list = (repos || discoveredRepos()).filter((r) => r && r.path);
|
|
98
|
+
const rel = [], log = [], seen = new Set();
|
|
99
|
+
const add = (into, f) => { const k = `${f.repo}|${f.text}`.toLowerCase(); if (!seen.has(k)) { seen.add(k); into.push(f); } };
|
|
100
|
+
const active = [];
|
|
101
|
+
for (const r of list) {
|
|
102
|
+
const tags = tagsIn(r.path, since), notes = changelogIn(r.path, since);
|
|
103
|
+
const ghr = useGh && (tags.length || notes.length) ? ghReleasesIn(r.path, since) : [];
|
|
104
|
+
for (const g of ghr) add(rel, { repo: r.name, date: g.date, kind: "release", text: `released ${g.tag}${g.name && g.name !== g.tag ? ` "${g.name}"` : ""}` });
|
|
105
|
+
for (const t of tags) add(rel, { repo: r.name, date: t.date, kind: "release", text: `released ${t.tag}` });
|
|
106
|
+
for (const n of notes) add(log, { repo: r.name, date: n.date, kind: "changelog", text: `${n.version ? n.version + ": " : ""}${n.text}` });
|
|
107
|
+
if (sh(`git -C ${q(r.path)} log --since=${q(sinceIso)} --oneline -1 2>/dev/null`).trim()) active.push(r);
|
|
108
|
+
}
|
|
109
|
+
let cs = commits(active, sinceIso, true); if (!cs.length) cs = commits(active, sinceIso, false);
|
|
110
|
+
const work = []; for (const c of cs) add(work, { repo: c.repo, date: c.date, kind: "commit", text: c.subject.slice(0, 300) });
|
|
111
|
+
// a busy week's changelog can't crowd out the commits
|
|
112
|
+
const top = [...rel.slice(0, 15), ...log.slice(0, 60)];
|
|
113
|
+
return [...top, ...work.slice(0, MAX_FACTS - top.length)];
|
|
114
|
+
}
|
|
115
|
+
const factLine = (f, i) => `[${i + 1}] ${f.repo} · ${f.date} · ${f.kind}: ${f.text}`;
|
|
116
|
+
|
|
117
|
+
// ---- never invent: what a draft claims has to be in the facts -----------------------
|
|
118
|
+
// The numbers in a text ("0.46.1", "1,000", "40"), without a thread's "1/3" or a
|
|
119
|
+
// list's "2." markers; dates in the facts count by their parts too ("2026-10-06" → 10, 6).
|
|
120
|
+
function numbersIn(text) {
|
|
121
|
+
const t = String(text || "").replace(/(^|\n)\s*\d+\s*(?:\/\s*\d*|[.)])[ \t]+/g, "$1").replace(/\(?\b\d+\s*\/\s*\d+\)?/g, " ");
|
|
122
|
+
return (t.match(/\d+(?:[.,]\d+)*/g) || []).map((n) => (/^\d{1,3}(,\d{3})+$/.test(n) ? n.replace(/,/g, "") : n));
|
|
123
|
+
}
|
|
124
|
+
function numberSet(texts) {
|
|
125
|
+
const s = new Set();
|
|
126
|
+
for (const t of texts) {
|
|
127
|
+
for (const n of String(t).match(/\d+(?:[.,]\d+)*/g) || []) { s.add(n); s.add(n.replace(/,/g, "")); for (const p of n.split(/[.,-]/)) { s.add(p); s.add(String(Number(p))); } }
|
|
128
|
+
for (const d of String(t).match(/\d{4}-\d{2}-\d{2}/g) || []) for (const p of d.split("-")) { s.add(p); s.add(String(Number(p))); }
|
|
129
|
+
}
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
// Words that are capitalised in the middle of a sentence (or inside, like GitHub):
|
|
133
|
+
// names of products, people and companies. Starting a sentence, a line or a
|
|
134
|
+
// bullet doesn't count.
|
|
135
|
+
function namesIn(text) {
|
|
136
|
+
const s = String(text || ""), out = [];
|
|
137
|
+
for (const m of s.matchAll(/\p{L}[\p{L}\p{N}'’-]*/gu)) {
|
|
138
|
+
const w = m[0].replace(/['’]s$/i, "").replace(/['’-]+$/, ""), before = s.slice(0, m.index);
|
|
139
|
+
if (/#$/.test(before)) continue; // a hashtag is style, not a claim
|
|
140
|
+
const start = !before.trim() || /[.!?:;]["'”’)\]]*\s*$/.test(before) || /(^|\n)[\s\p{P}\p{S}\p{Extended_Pictographic}\d]*$/u.test(before);
|
|
141
|
+
if (w.length > 1 && ((/^\p{Lu}/u.test(w) && !start) || /\p{Lu}/u.test(w.slice(1)))) out.push(w);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
// Ordinary words that are capitalised anyway.
|
|
146
|
+
const ALLOW = new Set(("i i'm i've i'd i'll ai cli ui ux api apis ok pr prs ci cd mvp dev devs json html css js npm git linkedin " +
|
|
147
|
+
"monday tuesday wednesday thursday friday saturday sunday january february march april may june july august september october november december").split(" "));
|
|
148
|
+
// Claims no fact could back: numbers of users or downloads in words, testimonials, money.
|
|
149
|
+
const METRIC = /(?:\b(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|twenty|thirty|fifty|hundreds?|thousands?|millions?|dozens?|several|many|countless)|\d[\d,.]*\s*[kKm]?\+?)\s+(?:\w+\s+)?(?:users?|customers?|clients?|downloads?|installs?|stars?|sign-?ups?|companies|teams|subscribers?|followers?|developers|devs|people|businesses)\b/gi;
|
|
150
|
+
const PRAISE = /\b(?:customers?|users?|clients?|people|everyone)\s+(?:love|loved|loves|say|said|told|are saying|rave)\b|\b(?:revenue|MRR|ARR|paying customers?|sold out)\b/gi;
|
|
151
|
+
// What a draft says that the facts (or your own voice examples, for names) don't
|
|
152
|
+
// show: [token], [] when it all checks out. Numbers must be in the facts; names in
|
|
153
|
+
// the facts or your examples; `code` and file names in the facts.
|
|
154
|
+
function checkClaims(text, facts, voice = []) {
|
|
155
|
+
const ft = facts.map((f) => (typeof f === "string" ? f : `${f.repo} ${f.date} ${f.text}`)), lower = ft.join("\n").toLowerCase(), mine = voice.join("\n").toLowerCase();
|
|
156
|
+
const nums = numberSet(ft), bad = new Set();
|
|
157
|
+
// a version or decimal is whole ("1.2" for 1.2.0, never "2.0"); a plain number may be a part of one, or a date's
|
|
158
|
+
const dotted = [...nums].filter((x) => /\./.test(x)), known = (n) => (/\./.test(n) ? dotted.some((x) => x === n || x.startsWith(n + ".")) : nums.has(n) || nums.has(String(Number(n))));
|
|
159
|
+
for (const n of numbersIn(text)) if (!known(n)) bad.add(n);
|
|
160
|
+
for (const w of namesIn(text)) { const l = w.toLowerCase(); if (!ALLOW.has(l) && !lower.includes(l) && !mine.includes(l)) bad.add(w); }
|
|
161
|
+
for (const m of String(text).matchAll(/`([^`]+)`/g)) if (!lower.includes(m[1].toLowerCase())) bad.add(m[1]);
|
|
162
|
+
for (const m of String(text).matchAll(/\b[\w-]+\.(?:m?js|cjs|tsx?|md|json|py|go|rs|java|kt|sh|ya?ml|toml|css|html)\b/gi)) if (!lower.includes(m[0].toLowerCase())) bad.add(m[0]);
|
|
163
|
+
for (const re of [METRIC, PRAISE]) for (const m of String(text).matchAll(re)) if (!lower.includes(m[0].toLowerCase())) bad.add(m[0]);
|
|
164
|
+
return [...bad];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- drafting ----------------------------------------------------------------------
|
|
168
|
+
function extractJson(s) {
|
|
169
|
+
const t = String(s || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim(), a = t.indexOf("{"), b = t.lastIndexOf("}");
|
|
170
|
+
if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
// The AI's answer as drafts, by kind: [{ kind, text, facts: [n] }].
|
|
174
|
+
function parseDrafts(raw) {
|
|
175
|
+
const j = extractJson(raw), list = j && Array.isArray(j.posts) ? j.posts : [];
|
|
176
|
+
return list.map((p, i) => ({ kind: KINDS.includes(p && p.kind) ? p.kind : KINDS[i] || "", text: String((p && p.text) || "").trim().slice(0, MAX_TEXT), facts: (Array.isArray(p && p.facts) ? p.facts : []).map(Number).filter(Number.isInteger) }))
|
|
177
|
+
.filter((p) => p.kind && p.text);
|
|
178
|
+
}
|
|
179
|
+
// A draft checked against the facts: the facts it cites that exist, and what it
|
|
180
|
+
// claims that they don't show. ok when it cites one at least and claims nothing more.
|
|
181
|
+
function vetDraft(p, facts, voice = []) {
|
|
182
|
+
const cited = [...new Set(p.facts)].filter((n) => n >= 1 && n <= facts.length);
|
|
183
|
+
const unsupported = checkClaims(p.text, cited.length ? cited.map((n) => facts[n - 1]) : facts, voice);
|
|
184
|
+
return { ...p, facts: cited, unsupported, ok: cited.length > 0 && !unsupported.length };
|
|
185
|
+
}
|
|
186
|
+
const SYSTEM = `You draft LinkedIn posts for a developer, from their real work this week, in their own voice. ` +
|
|
187
|
+
`Write exactly 3 posts: "shipped" (what was built or released this week), "learned" (one problem they hit and how it was solved, as the facts show it), ` +
|
|
188
|
+
`and "long" (a slightly deeper write-up of the best item: a longer post, or a short thread of 3 to 5 parts numbered 1/, 2/… with a blank line between). ` +
|
|
189
|
+
`Write the way their example posts do (length, tone, emoji, hashtags, how they open and close), but take nothing from the examples as fact: they're old posts. ` +
|
|
190
|
+
`Every claim must come from the numbered facts. Never invent anything: no numbers, versions, names, users, downloads, revenue, results or testimonials the facts don't show, ` +
|
|
191
|
+
`and no counts ("12 commits"). Use versions and names exactly as the facts write them. Plain text, no markdown, no links unless a fact has one. Never say it was written by AI. ` +
|
|
192
|
+
`Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","facts":[1,4]},{"kind":"learned","text":"…","facts":[…]},{"kind":"long","text":"…","facts":[…]}]}, ` +
|
|
193
|
+
`where "facts" lists the numbers of every fact the post uses.`;
|
|
194
|
+
// Draft this week's 3 posts and put them on the Dashboard (waiting on you). With
|
|
195
|
+
// no AI connected it says so and does nothing else: nothing is read, written or
|
|
196
|
+
// logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
|
|
197
|
+
// and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
|
|
198
|
+
async function draftPosts({ ask = write, now = Date.now(), days = 7, repos, gh, paths = PATHS } = {}) {
|
|
199
|
+
if (ask === write && !resolveProvider()) return { error: NO_AI, code: "not-connected" };
|
|
200
|
+
const voice = loadVoice(paths.voice);
|
|
201
|
+
if (!voice.length) return { error: noVoice(paths.voice), code: "no-voice" };
|
|
202
|
+
const facts = gatherFacts({ days, now, repos, gh });
|
|
203
|
+
if (!facts.length) return { error: `Nothing in git in the last ${days} days (no commits, release tags or dated CHANGELOG.md sections) in the repos Symbiot scans, so there's nothing true to post about. Nothing was drafted.`, code: "no-facts" };
|
|
204
|
+
const listed = facts.map(factLine).join("\n");
|
|
205
|
+
const prompt = `Example posts of mine (my voice; not facts about this week):\n\n${voice.slice(0, 10).map((v, i) => `Example ${i + 1}:\n${v}`).join("\n\n")}\n\n` +
|
|
206
|
+
`This week's facts (the only things you may claim), numbered:\n${listed}\n\nDraft the 3 posts.`;
|
|
207
|
+
const said = async (sys, p) => { const raw = await ask(sys, p); return !raw || /^\(?couldn't reach the model/i.test(raw) ? null : raw; };
|
|
208
|
+
const first = await said(SYSTEM, prompt);
|
|
209
|
+
if (first == null) return { error: "Your AI didn't answer (or turned the request down). Nothing was drafted. Check it with symbiot whoami, then try again.", code: "no-answer" };
|
|
210
|
+
const byKind = new Map(); for (const p of parseDrafts(first)) if (!byKind.has(p.kind)) byKind.set(p.kind, vetDraft(p, facts, voice));
|
|
211
|
+
// once more for any that's missing or claims what git doesn't show, told what was wrong
|
|
212
|
+
const wrong = KINDS.filter((k) => !byKind.get(k) || !byKind.get(k).ok);
|
|
213
|
+
if (wrong.length) {
|
|
214
|
+
const why = wrong.map((k) => { const d = byKind.get(k); return `- "${k}": ${!d ? "missing" : !d.facts.length ? "it cited no facts" : `it claims ${d.unsupported.map((x) => `"${x}"`).join(", ")}, which the facts don't show`}`; }).join("\n");
|
|
215
|
+
const again = await said(SYSTEM, `${prompt}\n\nYour last answer had problems:\n${why}\n\nWrite only ${wrong.map((k) => `"${k}"`).join(", ")} again, in the same JSON shape, using only what the facts show.`);
|
|
216
|
+
for (const p of again ? parseDrafts(again) : []) if (wrong.includes(p.kind)) { const v = vetDraft(p, facts, voice); if (v.ok || !byKind.get(p.kind)) byKind.set(p.kind, v); }
|
|
217
|
+
}
|
|
218
|
+
const good = KINDS.map((k) => byKind.get(k)).filter((d) => d && d.ok), dropped = KINDS.map((k) => byKind.get(k)).filter((d) => d && !d.ok);
|
|
219
|
+
const logDropped = () => { for (const x of dropped) logAction("dropped", { id: "", kind: x.kind, text: x.text, platform: PLATFORM }, now, paths, { why: x.facts.length ? `claims ${x.unsupported.join(", ")}, which git doesn't show` : "cited no facts" }); };
|
|
220
|
+
const droppedOut = dropped.map((x) => ({ kind: x.kind, unsupported: x.unsupported, cited: x.facts.length }));
|
|
221
|
+
// none that git backs: the drafts already waiting stay
|
|
222
|
+
if (!good.length) { logDropped(); return { error: "Your AI's drafts all claimed things git doesn't show (or cited nothing), so none was kept. Try again, or add to voice.md.", code: "all-dropped", dropped: droppedOut }; }
|
|
223
|
+
const d = loadPosts(paths);
|
|
224
|
+
for (const p of d.posts) if (p.status === "waiting") { p.status = "replaced"; p.replaced = now; logAction("replaced", p, now, paths); }
|
|
225
|
+
const posts = good.map((g) => ({ id: randomBytes(4).toString("hex"), kind: g.kind, text: g.text, facts: g.facts, sources: g.facts.map((n) => factLine(facts[n - 1], n - 1)), platform: PLATFORM, status: "waiting", drafted: now }));
|
|
226
|
+
d.posts = [...posts, ...d.posts].slice(0, KEEP);
|
|
227
|
+
savePosts(d, paths);
|
|
228
|
+
for (const p of posts) logAction("drafted", p, now, paths);
|
|
229
|
+
logDropped();
|
|
230
|
+
return { ok: true, posts, dropped: droppedOut, facts: facts.length, voice: voice.length };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- the drafts, and what you do with them ------------------------------------------
|
|
234
|
+
function loadPosts(paths = PATHS) { try { const d = JSON.parse(readFileSync(paths.posts, "utf8")); return { posts: Array.isArray(d.posts) ? d.posts : [] }; } catch { return { posts: [] }; } }
|
|
235
|
+
function savePosts(d, paths = PATHS) { try { saveText(paths.posts, JSON.stringify(d, null, 2)); return true; } catch { return false; } }
|
|
236
|
+
// The log: one JSON line per action, only ever appended to. Yours only (0600).
|
|
237
|
+
function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
|
|
238
|
+
try {
|
|
239
|
+
mkdirSync(dirname(paths.log), { recursive: true });
|
|
240
|
+
appendFileSync(paths.log, JSON.stringify({ ts: now, date: new Date(now).toISOString(), action, id: p.id, kind: p.kind, platform: p.platform || PLATFORM, text: p.text, ...extra }) + "\n", { mode: 0o600 });
|
|
241
|
+
try { chmodSync(paths.log, 0o600); } catch {}
|
|
242
|
+
return true;
|
|
243
|
+
} catch { return false; }
|
|
244
|
+
}
|
|
245
|
+
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 []; } }
|
|
246
|
+
// What the Dashboard shows: the drafts waiting on you, the last few you dealt with,
|
|
247
|
+
// and whether it can draft (an AI connected, voice examples).
|
|
248
|
+
function postsState(paths = PATHS) {
|
|
249
|
+
const d = loadPosts(paths);
|
|
250
|
+
return { posts: d.posts.filter((p) => p.status === "waiting"), done: d.posts.filter((p) => p.status === "approved" || p.status === "skipped").slice(0, 5),
|
|
251
|
+
voice: { count: loadVoice(paths.voice).length, file: paths.voice }, connected: !!resolveProvider(), share: SHARE_URL, labels: KIND_LABEL };
|
|
252
|
+
}
|
|
253
|
+
// One waiting draft, by id (or the start of one), and the store it's in.
|
|
254
|
+
function waiting(id, paths) {
|
|
255
|
+
const d = loadPosts(paths), s = String(id || "").trim();
|
|
256
|
+
const p = s && (d.posts.find((x) => x.id === s) || (s.length >= 3 && d.posts.filter((x) => x.id.startsWith(s)).length === 1 && d.posts.find((x) => x.id.startsWith(s))));
|
|
257
|
+
if (!p) return { error: `No draft ${s || "(no id)"}. symbiot post list shows them.` };
|
|
258
|
+
if (p.status !== "waiting") return { error: `That draft was ${p.status} already.` };
|
|
259
|
+
return { d, p };
|
|
260
|
+
}
|
|
261
|
+
// Edit: your words replace the draft's; it stays waiting. What it names that git
|
|
262
|
+
// doesn't show is said (unsupported), not blocked: you're allowed to say it.
|
|
263
|
+
function editPost(id, text, { now = Date.now(), paths = PATHS } = {}) {
|
|
264
|
+
text = String(text == null ? "" : text).trim();
|
|
265
|
+
if (!text) return { error: "Give the post's new text." };
|
|
266
|
+
if (text.length > MAX_TEXT) return { error: `LinkedIn takes ${MAX_TEXT} characters a post; that's ${text.length}.` };
|
|
267
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
268
|
+
w.p.text = text; w.p.edited = now; savePosts(w.d, paths); logAction("edited", w.p, now, paths);
|
|
269
|
+
const unsupported = checkClaims(text, w.p.sources || [], loadVoice(paths.voice));
|
|
270
|
+
return { ok: true, post: w.p, ...(unsupported.length ? { unsupported } : {}) };
|
|
271
|
+
}
|
|
272
|
+
function skipPost(id, { now = Date.now(), paths = PATHS } = {}) {
|
|
273
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
274
|
+
w.p.status = "skipped"; w.p.skipped = now; savePosts(w.d, paths); logAction("skipped", w.p, now, paths);
|
|
275
|
+
return { ok: true, post: w.p };
|
|
276
|
+
}
|
|
277
|
+
// Approve: your yes to this one post. It's logged, then copied to your clipboard
|
|
278
|
+
// (`copy`: copyText in a terminal; the app's page copies it itself, so it passes
|
|
279
|
+
// null) for you to paste into LinkedIn's share box (`share`). Symbiot doesn't post
|
|
280
|
+
// or schedule it.
|
|
281
|
+
function approvePost(id, { copy = copyText, now = Date.now(), paths = PATHS } = {}) {
|
|
282
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
283
|
+
w.p.status = "approved"; w.p.approved = now; savePosts(w.d, paths); logAction("approved", w.p, now, paths);
|
|
284
|
+
const copied = copy ? copy(w.p.text) : "";
|
|
285
|
+
return { ok: true, post: w.p, copied, share: SHARE_URL, note: "Symbiot doesn't post or schedule it: paste it into LinkedIn's share box and post it yourself." };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- clipboard, and opening the share box, from a terminal -----------------------
|
|
289
|
+
// The first clipboard tool that's here and takes it: its name, or "" when none did.
|
|
290
|
+
function copyText(text) {
|
|
291
|
+
const tools = process.platform === "darwin" ? [["pbcopy", []]] : process.platform === "win32" ? [["clip", []]]
|
|
292
|
+
: [...(process.env.WAYLAND_DISPLAY ? [["wl-copy", []]] : []), ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
|
|
293
|
+
for (const [cmd, args] of tools) {
|
|
294
|
+
if (!hasCmd(cmd)) continue;
|
|
295
|
+
// only stdin piped: wl-copy and xclip stay behind to serve the clipboard, and must not hold our pipes
|
|
296
|
+
const r = spawnSync(cmd, args, { input: String(text), stdio: ["pipe", "ignore", "ignore"], timeout: 4000 });
|
|
297
|
+
if (r.status === 0) return cmd;
|
|
298
|
+
}
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
function openUrl(url) {
|
|
302
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
303
|
+
try { spawn(cmd, args, { detached: true, stdio: "ignore" }).unref(); return true; } catch { return false; }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- replies: who might be a customer ------------------------------------------------
|
|
307
|
+
// A comment or mention that asks how to install it, what it costs or about its
|
|
308
|
+
// licence, or about using it in a team: "maybe a customer" on the Dashboard.
|
|
309
|
+
// Gives why ("install", "pricing", "team") or "".
|
|
310
|
+
const CUSTOMER = [
|
|
311
|
+
["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],
|
|
312
|
+
["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],
|
|
313
|
+
["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],
|
|
314
|
+
];
|
|
315
|
+
function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
|
|
316
|
+
|
|
317
|
+
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 };
|
package/reports.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// symbiot — Reports: what agents wrote up for you, in one place. A run that
|
|
2
|
+
// finds things (an audit, findings, a plan, a pitch) leaves them as a `.md` in
|
|
3
|
+
// its folder's .symbiot/ (handover.mjs HANDBACK tells every brief so). Before
|
|
4
|
+
// this, the only way to read one was to find the file by hand.
|
|
5
|
+
//
|
|
6
|
+
// - Where: the .symbiot/ of every folder runs work in (your repos, and the runs
|
|
7
|
+
// of their own under ~/.config/symbiot/drafts: handback.mjs runFolders).
|
|
8
|
+
// - What counts: any .md there except the files Symbiot and the run talk through
|
|
9
|
+
// (TASKS, QUESTIONS, ANSWERS, HANDOFF, SKIPPED) and the briefs handed in
|
|
10
|
+
// (BRIEF*.md). Its title is its first `# ` heading, else the file name.
|
|
11
|
+
// - New: a report you haven't opened since it last changed. Home shows how many
|
|
12
|
+
// (home.mjs); opening one marks it read.
|
|
13
|
+
// - Reading: only a file this list found can be read, by its id, never by a
|
|
14
|
+
// path; it's shown as HTML made here (mdHtml), with everything escaped first.
|
|
15
|
+
//
|
|
16
|
+
// Stored in ~/.config/symbiot/reports.json, yours only: { seen: { id: mtime } }.
|
|
17
|
+
import { join, basename } from "node:path";
|
|
18
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync } from "node:fs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { CONFIG_DIR } from "./core.mjs";
|
|
21
|
+
import { runFolders, laneOf, runTitle } from "./handback.mjs";
|
|
22
|
+
import { runningHandoff } from "./agents.mjs";
|
|
23
|
+
import { repoPathMap } from "./scan.mjs";
|
|
24
|
+
|
|
25
|
+
const SEEN_FILE = join(CONFIG_DIR, "reports.json");
|
|
26
|
+
const NOT_REPORTS = /^(?:TASKS|QUESTIONS|ANSWERS|HANDOFF|SKIPPED|CLAUDE|AGENTS)\.md$|^BRIEF/i;
|
|
27
|
+
const MAX_READ = 512 * 1024, MAX_LIST = 200;
|
|
28
|
+
const idOf = (file) => createHash("sha1").update(file).digest("hex").slice(0, 12);
|
|
29
|
+
|
|
30
|
+
function loadSeen(file = SEEN_FILE) { try { const d = JSON.parse(readFileSync(file, "utf8")); return { seen: d && typeof d.seen === "object" && d.seen ? d.seen : {} }; } catch { return { seen: {} }; } }
|
|
31
|
+
function saveSeen(d, file = SEEN_FILE) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(file, JSON.stringify(d), { mode: 0o600 }); try { chmodSync(file, 0o600); } catch {} return true; } catch { return false; } }
|
|
32
|
+
|
|
33
|
+
// A report's title: its first "# " heading, without markdown; else its file name.
|
|
34
|
+
function titleOf(text, name) {
|
|
35
|
+
const h = String(text || "").match(/^#\s+(.+)$/m);
|
|
36
|
+
return (h ? h[1].replace(/[*_`]/g, "").trim() : "") || name.replace(/\.md$/i, "").replace(/[-_]+/g, " ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Every report runs left, newest first: [{ id, title, name, file, folder, lane,
|
|
40
|
+
// run, size, mtime, new, running }]. `folders`, `running` and `seen` can be given
|
|
41
|
+
// (the tests do).
|
|
42
|
+
function listReports({ map, folders, running = runningHandoff, seen } = {}) {
|
|
43
|
+
if (!folders) { map = map || repoPathMap(); folders = runFolders(map); }
|
|
44
|
+
const s = (seen || loadSeen()).seen, out = [];
|
|
45
|
+
for (const folder of folders) {
|
|
46
|
+
const dir = join(folder, ".symbiot");
|
|
47
|
+
let names = []; try { names = readdirSync(dir).filter((f) => /\.md$/i.test(f) && !NOT_REPORTS.test(f)); } catch { continue; }
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const file = join(dir, name);
|
|
50
|
+
let st; try { st = statSync(file); } catch { continue; }
|
|
51
|
+
if (!st.isFile() || !st.size) continue;
|
|
52
|
+
let head = ""; try { head = readFileSync(file, "utf8").slice(0, 4000); } catch { continue; }
|
|
53
|
+
const id = idOf(file), mtime = Math.round(st.mtimeMs);
|
|
54
|
+
out.push({ id, title: titleOf(head, name), name, file, folder, lane: map ? laneOf(folder, map) : basename(folder), run: runTitle(folder), size: st.size, mtime, new: s[id] !== mtime, running: !!running(folder) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_LIST);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One report, by its id: its text, as HTML too. Opening it marks it read.
|
|
61
|
+
function readReport(id, { list, mark = true, seenFile = SEEN_FILE } = {}) {
|
|
62
|
+
const r = (list || listReports()).find((x) => x.id === String(id || ""));
|
|
63
|
+
if (!r) return { error: "No report by that id: it may have been moved or deleted." };
|
|
64
|
+
let text = ""; try { text = readFileSync(r.file, "utf8"); } catch (e) { return { error: `Couldn't read ${r.file}: ${e.message}` }; }
|
|
65
|
+
const cut = text.length > MAX_READ; if (cut) text = text.slice(0, MAX_READ);
|
|
66
|
+
if (mark) { const d = loadSeen(seenFile); d.seen[r.id] = r.mtime; saveSeen(d, seenFile); }
|
|
67
|
+
return { ...r, new: false, text, html: mdHtml(text) + (cut ? "<p class='muted'>(cut at 512 KB: open the file for the rest)</p>" : "") };
|
|
68
|
+
}
|
|
69
|
+
// Mark them all read (the list's "Mark all read").
|
|
70
|
+
function markAllRead({ list, seenFile = SEEN_FILE } = {}) {
|
|
71
|
+
const d = loadSeen(seenFile); for (const r of list || listReports()) d.seen[r.id] = r.mtime;
|
|
72
|
+
return saveSeen(d, seenFile) ? { ok: true } : { error: "Couldn't write " + seenFile };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- markdown, as safe HTML ----------------------------------------------------------
|
|
76
|
+
// What reports use: headings, paragraphs, lists (numbered or not, nested by
|
|
77
|
+
// indent), tables, code (fenced and inline), quotes, rules, bold, italic and
|
|
78
|
+
// links (http, https and mailto only). Everything is escaped before any tag is
|
|
79
|
+
// made, so a report can't put its own HTML or script on the page.
|
|
80
|
+
const escHtml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
81
|
+
function inline(s) {
|
|
82
|
+
const code = [];
|
|
83
|
+
let t = escHtml(s).replace(/`([^`]+)`/g, (_, c) => { code.push(c); return `\u0000${code.length - 1}\u0000`; });
|
|
84
|
+
t = t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (_, txt, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${txt}</a>`)
|
|
85
|
+
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/__([^_]+)__/g, "<b>$1</b>")
|
|
86
|
+
.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, "$1<i>$2</i>").replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:!?]|$)/g, "$1<i>$2</i>")
|
|
87
|
+
.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
|
88
|
+
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${code[+i]}</code>`);
|
|
89
|
+
}
|
|
90
|
+
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
|
|
91
|
+
function mdHtml(md) {
|
|
92
|
+
const lines = String(md || "").replace(/\r\n?/g, "\n").split("\n"), out = [];
|
|
93
|
+
let i = 0, para = [];
|
|
94
|
+
const flush = () => { if (para.length) { out.push(`<p>${inline(para.join(" "))}</p>`); para = []; } };
|
|
95
|
+
while (i < lines.length) {
|
|
96
|
+
const l = lines[i];
|
|
97
|
+
const fence = l.match(/^\s*(```|~~~)/);
|
|
98
|
+
if (fence) { flush(); const body = []; i++; while (i < lines.length && !lines[i].trim().startsWith(fence[1])) body.push(lines[i++]); i++; out.push(`<pre><code>${escHtml(body.join("\n"))}</code></pre>`); continue; }
|
|
99
|
+
const h = l.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
100
|
+
if (h) { flush(); const n = Math.min(h[1].length + 1, 6); out.push(`<h${n}>${inline(h[2])}</h${n}>`); i++; continue; }
|
|
101
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push("<hr>"); i++; continue; }
|
|
102
|
+
if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
|
103
|
+
flush(); const head = cells(l); i += 2; const rows = [];
|
|
104
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
|
|
105
|
+
out.push(`<div class="rtable"><table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (/^\s*>/.test(l)) { flush(); const q = []; while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, "")); out.push(`<blockquote>${mdHtml(q.join("\n"))}</blockquote>`); continue; }
|
|
109
|
+
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(l)) { flush(); i = list(lines, i, out); continue; }
|
|
110
|
+
if (!l.trim()) { flush(); i++; continue; }
|
|
111
|
+
para.push(l.trim()); i++;
|
|
112
|
+
}
|
|
113
|
+
flush();
|
|
114
|
+
return out.join("\n");
|
|
115
|
+
}
|
|
116
|
+
// A list from line i, its items' deeper lines as nested lists; gives the next line.
|
|
117
|
+
function list(lines, i, out) {
|
|
118
|
+
const ind = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length, base = ind(lines[i]);
|
|
119
|
+
const ordered = /^\s*\d+[.)]\s/.test(lines[i]), items = [];
|
|
120
|
+
while (i < lines.length) {
|
|
121
|
+
const l = lines[i];
|
|
122
|
+
if (!l.trim()) { if (i + 1 < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[i + 1]) && ind(lines[i + 1]) >= base) { i++; continue; } break; }
|
|
123
|
+
const m = l.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/), d = ind(l);
|
|
124
|
+
if (m && d === base) { items.push({ text: m[1].replace(/^\[([ xX])\]\s+/, (_, x) => (x === " " ? "☐ " : "☑ ")), sub: [] }); i++; continue; }
|
|
125
|
+
if (d > base && items.length) { if (m) { const sub = []; i = list(lines, i, sub); items[items.length - 1].sub.push(...sub); } else { items[items.length - 1].text += " " + l.trim(); i++; } continue; }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
const tag = ordered ? "ol" : "ul";
|
|
129
|
+
out.push(`<${tag}>${items.map((it) => `<li>${inline(it.text)}${it.sub.join("")}</li>`).join("")}</${tag}>`);
|
|
130
|
+
return i;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// How many reports are new, and the newest one's title (Home's droplet).
|
|
134
|
+
function reportsNews(opts) { const l = listReports(opts).filter((r) => r.new); return { count: l.length, latest: l[0] ? l[0].title : "" }; }
|
|
135
|
+
|
|
136
|
+
export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, markAllRead, reportsNews, mdHtml };
|
package/sandbox.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// symbiot — Sandbox: `symbiot app --fresh` opens a brand-new Symbiot, so you can
|
|
2
|
+
// walk through first run and setup the way someone new to it would, without
|
|
3
|
+
// touching yours.
|
|
4
|
+
//
|
|
5
|
+
// Everything Symbiot keeps lives under your home folder (~/.config/symbiot:
|
|
6
|
+
// memory, links, lanes, tasks, the hidden browser's sign-ins), and so do the
|
|
7
|
+
// things it reads to know you (~/.claude.json for connectors, ~/.config/anthropic,
|
|
8
|
+
// git's ~/.gitconfig). So the sandbox is a second Symbiot with a throwaway home:
|
|
9
|
+
// - HOME is a new empty folder under the system's temp folder, deleted when the
|
|
10
|
+
// app quits (--keep keeps it, to look at what setup wrote).
|
|
11
|
+
// - No AI keys, model or config locations come from your environment (AI_ENV),
|
|
12
|
+
// so it starts unconnected, like a new install.
|
|
13
|
+
// - It scans nothing of yours: the empty home, until you add a folder.
|
|
14
|
+
// - Its own port, and it never offers or runs an update (server.mjs SANDBOX): an
|
|
15
|
+
// update would replace your real install.
|
|
16
|
+
// - The app shows "sandbox" by its version, so you can tell the two apart.
|
|
17
|
+
// What it can't fence off: a folder you add yourself is read (and Send to repos
|
|
18
|
+
// writes .symbiot/TASKS.md there), and agents it starts run as you on this
|
|
19
|
+
// computer, though without your Claude sign-in (it lives in your home).
|
|
20
|
+
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
const PORT = 7381; // not the app's 7391, so both can run; it moves on if this one's taken
|
|
26
|
+
// What would connect it, or point it at your config, from the environment.
|
|
27
|
+
const AI_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "SYMBIOT_MODEL",
|
|
28
|
+
"SYMBIOT_SCAN_HOME", "SYMBIOT_PORT", "SYMBIOT_RELAUNCH", "SYMBIOT_UPDATE_CMD", "CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "GIT_CONFIG_GLOBAL"];
|
|
29
|
+
|
|
30
|
+
// The environment the sandbox's app runs in: yours, with its own home and none of the above.
|
|
31
|
+
function sandboxEnv(home, env = process.env, port = PORT) {
|
|
32
|
+
const out = { ...env };
|
|
33
|
+
for (const k of AI_ENV) delete out[k];
|
|
34
|
+
return { ...out, HOME: home, USERPROFILE: home, SYMBIOT_SANDBOX: home, SYMBIOT_FORCE_NEW: "1", SYMBIOT_PORT: String(port) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Start it: a new home, the app in it, and the home gone when the app stops.
|
|
38
|
+
// `args` go on to `symbiot app` (--plain, --all…). Resolves with the app's exit code.
|
|
39
|
+
function runSandbox({ bin, args = [], keep = false, log = console.log, port = PORT } = {}) {
|
|
40
|
+
const home = realpathSync(mkdtempSync(join(tmpdir(), "symbiot-fresh-")));
|
|
41
|
+
log(`Sandbox: a brand-new Symbiot, with its own empty home at ${home}.`);
|
|
42
|
+
log("No AI connected, no memory, no linked accounts, no lanes, none of your repos. Nothing of yours is read or changed.");
|
|
43
|
+
log(keep ? "That folder stays when you quit (--keep)." : "Quit it (the Quit button, or Ctrl+C here) and that folder is deleted.");
|
|
44
|
+
const child = spawn(process.execPath, [bin, "app", ...args], { env: sandboxEnv(home, process.env, port), stdio: "inherit" });
|
|
45
|
+
const stop = () => {}; // Ctrl+C reaches the app too; wait for it to quit, then clean up
|
|
46
|
+
process.on("SIGINT", stop); process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
47
|
+
return new Promise((resolve) => child.on("exit", (code) => {
|
|
48
|
+
process.off("SIGINT", stop);
|
|
49
|
+
if (!keep) { try { rmSync(home, { recursive: true, force: true }); log("Sandbox closed; its home is deleted."); } catch (e) { log(`Couldn't delete ${home}: ${e.message}`); } }
|
|
50
|
+
else log(`Sandbox closed. Its home is still at ${home}.`);
|
|
51
|
+
resolve(code || 0);
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { PORT as SANDBOX_PORT, AI_ENV, sandboxEnv, runSandbox };
|