symbiot 0.57.4 → 0.57.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/marketing.mjs CHANGED
@@ -7,20 +7,24 @@
7
7
  // markets: "[Dailify] …" as written, else the product its words name. Home shows the
8
8
  // lane as an orb among the projects, lit when something there needs you (home.mjs
9
9
  // marketingState). Low level on purpose: scan.mjs (laneMap) imports it.
10
- import { join, relative, basename } from "node:path";
11
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
10
+ import { join, relative, basename, dirname } from "node:path";
11
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
12
12
  import { spawnSync } from "node:child_process";
13
- import { CONFIG_DIR } from "./core.mjs";
13
+ import { createHash } from "node:crypto";
14
+ import { fileURLToPath } from "node:url";
15
+ import { CONFIG_DIR, loadConfig } from "./core.mjs";
14
16
  import { MARKETING } from "./handover.mjs";
15
17
 
16
18
  const MARKETING_DIR = join(CONFIG_DIR, "marketing");
19
+ const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
17
20
  const DRAFTS = "drafts";
18
21
  const README = `# Marketing
19
22
 
20
23
  Symbiot's marketing lane: posts, demo videos and screenshots, launches and campaigns for
21
24
  every product, not one repo's. Its agent drafts here, one folder per product under
22
- \`${DRAFTS}/\`; you review and approve each piece under Marketing in Symbiot, and post it
23
- yourself. Nothing here is ever posted, published or sent by an agent.
25
+ \`${DRAFTS}/\`, each post's text under \`## Post\` and its notes under \`## Notes\`. You
26
+ approve each one's preview under Marketing in Symbiot; only then does its agent post or
27
+ schedule it, through Symbiot's signed-in browser, with exactly the text you approved.
24
28
  `;
25
29
 
26
30
  // The lane's folder, made the first time a task goes to it: a README (its name on the
@@ -104,13 +108,109 @@ function draftFiles(dir = MARKETING_DIR, names = [], { max = 30 } = {}) {
104
108
  const tagLine = (head.match(/^\s*product:\s*(.+)$/im) || [])[1], folder = relative(root, d).split("/")[0] || "";
105
109
  const product = (tagLine || "").trim() || names.find((n) => n.toLowerCase() === folder.toLowerCase()) || (folder ? folder.charAt(0).toUpperCase() + folder.slice(1) : "");
106
110
  const title = (head.match(/^#\s+(.+)$/m) || [])[1];
107
- out.push({ file: p, rel: relative(dir, p), name: (title || basename(e.name, ".md").replace(/[-_]+/g, " ")).trim().slice(0, 120), product, at });
111
+ const rel = relative(dir, p); let st = null; try { st = statusOf(dir, rel, parseDraft(readFileSync(p, "utf8")).body); } catch {}
112
+ out.push({ file: p, rel, name: (title || basename(e.name, ".md").replace(/[-_]+/g, " ")).trim().slice(0, 120), product, at, status: st ? st.status : "" });
108
113
  }
109
114
  };
110
115
  walk(root, 0);
111
116
  return out.sort((a, b) => b.at - a.at).slice(0, max);
112
117
  }
113
118
 
119
+ // ---- a draft as the post it will be ---------------------------------------------------
120
+ // Opening a draft showed the raw file, its agent's working notes mixed into the post
121
+ // (the [Steve] LinkedIn series, 2026-10-08). A draft is now read into the post itself
122
+ // and the notes beside it, so the preview, the approval and what gets posted are the
123
+ // same text: the head's `key: value` lines, the text under `## Post`, and every other
124
+ // `## ` section as notes. One written before that: the post is what's between its first
125
+ // two `---` lines, and the rest is notes (with the picture and the date they name).
126
+ const MEDIA_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm" };
127
+ const SEE_MORE = 210; // where LinkedIn's feed cuts a post off with "…see more"
128
+ const HEAD_KEYS = { product: "product", platform: "platform", when: "when", schedule: "when", date: "when", media: "media", image: "media", video: "media", picture: "media" };
129
+ function parseDraft(text) {
130
+ const src = String(text || "").replace(/\r\n/g, "\n"), out = { title: "", product: "", platform: "", when: "", media: [], body: "", notes: "", format: "" };
131
+ const lines = src.split("\n"), head = [];
132
+ let i = 0;
133
+ for (; i < lines.length; i++) { const l = lines[i]; if (/^##\s/.test(l) || /^\s*-{3,}\s*$/.test(l)) break; head.push(l); }
134
+ for (const l of head) {
135
+ let m;
136
+ if (!out.title && (m = l.match(/^#\s+(.+?)\s*$/))) { out.title = m[1]; continue; }
137
+ if ((m = l.match(/^\s*([a-z]+)\s*:\s*(.+?)\s*$/i)) && HEAD_KEYS[m[1].toLowerCase()]) { const k = HEAD_KEYS[m[1].toLowerCase()]; if (k === "media") out.media.push(...m[2].split(/\s*,\s*/).map((x) => x.replace(/`/g, "").trim()).filter(Boolean)); else out[k] = out[k] || m[2]; }
138
+ }
139
+ const rest = lines.slice(i).join("\n");
140
+ if (/^##\s+(?:the\s+)?post\s*$/im.test(rest)) {
141
+ out.format = "sections";
142
+ const notes = [];
143
+ for (const part of rest.split(/^(?=##\s)/m)) {
144
+ const m = part.match(/^##[ \t]+([^\n]+?)[ \t]*(?:\n|$)([\s\S]*)$/); if (!m) continue;
145
+ if (/^(?:the\s+)?post$/i.test(m[1]) && !out.body) out.body = m[2].replace(/^\n+|\s+$/g, "");
146
+ else if (m[2].trim()) notes.push(/^notes?$/i.test(m[1]) ? m[2].trim() : `${m[1]}\n${m[2].trim()}`);
147
+ }
148
+ out.notes = notes.join("\n\n");
149
+ } else if (/^\s*-{3,}\s*$/m.test(rest)) {
150
+ out.format = "legacy";
151
+ const parts = rest.split(/^\s*-{3,}\s*$/m);
152
+ out.body = (parts[1] || "").replace(/^\n+|\s+$/g, "");
153
+ out.notes = parts.slice(2).join("\n").trim();
154
+ } else { out.format = "plain"; out.body = rest.replace(/^\n+|\s+$/g, ""); }
155
+ // what the notes name: its picture or video (`week-02-card.png`), and when it goes out
156
+ if (!out.media.length) out.media = [...new Set((out.notes.match(/[\w./-]+\.(?:png|jpe?g|gif|webp|mp4|mov|webm)\b/gi) || []))];
157
+ if (!out.when) { const w = out.notes.match(/(\d{4}-\d{2}-\d{2})(?:[ ,T]+(\d{1,2}:\d{2}))?/); if (w) out.when = w[2] ? `${w[1]} ${w[2]}` : w[1]; }
158
+ out.platform = (out.platform || "linkedin").toLowerCase();
159
+ return out;
160
+ }
161
+ // The post's text as the feed shows it before "…see more": cut at a word, at most SEE_MORE.
162
+ function seeMore(body, at = SEE_MORE) {
163
+ const t = String(body || "");
164
+ if (t.length <= at) return { shown: t, cut: false };
165
+ const head = t.slice(0, at), k = Math.max(head.lastIndexOf(" "), head.lastIndexOf("\n"));
166
+ return { shown: head.slice(0, k > at * 0.6 ? k : at).replace(/\s+$/, ""), cut: true };
167
+ }
168
+ // Who the post is from: config.profile ({ name, headline, avatar }), else your git name.
169
+ function author({ cfg = loadConfig(), dir = MARKETING_DIR } = {}) {
170
+ const p = (cfg && cfg.profile) || {};
171
+ let name = String(p.name || "").trim();
172
+ if (!name) name = String(spawnSync("git", ["-C", dir, "config", "user.name"], { encoding: "utf8", timeout: 5000 }).stdout || "").trim();
173
+ const avatar = p.avatar && existsSync(String(p.avatar)) && MEDIA_TYPES[String(p.avatar).split(".").pop().toLowerCase()] ? String(p.avatar) : "";
174
+ return { name: name || "You", headline: String(p.headline || "").trim(), avatar: !!avatar, initials: (name || "You").split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase() };
175
+ }
176
+ // A file of the lane's, by its path there, only if it is there (no ../ out of it).
177
+ function inLane(dir, rel) {
178
+ if (!rel || /\0/.test(rel)) return "";
179
+ const f = join(dir, String(rel));
180
+ try { const real = realpathSync(f), root = realpathSync(dir); return real.startsWith(root + "/") ? real : ""; } catch { return ""; }
181
+ }
182
+ // The text an approval is for: a draft's post changed after you approved it isn't approved.
183
+ const sigOf = (body) => createHash("sha256").update(String(body || "")).digest("hex").slice(0, 16);
184
+ const STATUS = ".symbiot/drafts.json";
185
+ function draftStatuses(dir = MARKETING_DIR) { try { const d = JSON.parse(readFileSync(join(dir, STATUS), "utf8")); return d && typeof d === "object" ? d : {}; } catch { return {}; } }
186
+ function statusOf(dir, rel, body) { const s = draftStatuses(dir)[rel]; return s && s.sig === sigOf(body) ? s : null; }
187
+ // Approve or skip a draft: kept by its text (a changed post asks again). { ok, status, draft } or { error }.
188
+ function setDraftStatus(rel, status, { dir = MARKETING_DIR, now = Date.now() } = {}) {
189
+ const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
190
+ if (!["approved", "skipped"].includes(status)) return { error: "Approve or skip it." };
191
+ const d = parseDraft(readFileSync(f, "utf8"));
192
+ if (!d.body) return { error: "That draft has no post in it yet." };
193
+ const all = draftStatuses(dir); all[rel] = { status, sig: sigOf(d.body), at: now };
194
+ try { mkdirSync(join(dir, ".symbiot"), { recursive: true }); writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
195
+ return { ok: true, status, draft: d };
196
+ }
197
+ // Everything the Marketing page needs to show a draft as the platform's post: who it's
198
+ // from, the text whole and cut where the feed cuts it, its hashtags, its picture or
199
+ // video (each a file next to it), its notes apart, and whether you approved it.
200
+ function draftPreview(rel, { dir = MARKETING_DIR, cfg } = {}) {
201
+ const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
202
+ const d = parseDraft(readFileSync(f, "utf8")), base = relative(realpathSync(dir), dirname(f));
203
+ const media = d.media.map((m) => { const r = join(base, m), ext = m.split(".").pop().toLowerCase(); return inLane(dir, r) && MEDIA_TYPES[ext] ? { rel: r, name: basename(m), kind: MEDIA_TYPES[ext].startsWith("video") ? "video" : "image" } : null; }).filter(Boolean);
204
+ const st = statusOf(dir, rel, d.body);
205
+ return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
206
+ media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0 };
207
+ }
208
+ // A picture or video a draft shows, or the avatar in config.profile: { file, type } or null.
209
+ function laneMedia(rel, { dir = MARKETING_DIR, cfg = loadConfig() } = {}) {
210
+ const f = rel === "avatar" ? String(((cfg && cfg.profile) || {}).avatar || "") : inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
211
+ return type && existsSync(f) ? { file: f, type } : null;
212
+ }
213
+
114
214
  // ---- its brief ----------------------------------------------------------------------------
115
215
  // The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
116
216
  // its tasks name and where each one's code and docs are, and its rules.
@@ -124,7 +224,9 @@ function marketingBrief(list = [], { map = {}, names = productNames(map) } = {})
124
224
  ...(where.length ? ["- **Products in these tasks** (read each one's README and docs for what it is and its end goal):", ...where.map((w) => ` - ${w.p}${w.path ? `: \`${w.path}\`` : " (not one of the user's repos here: ask in QUESTIONS.md where it lives, or hand it over)"}`)] : []),
125
225
  `- Write each piece as a \`.md\` under \`${DRAFTS}/<product>/\` (\`${DRAFTS}/dailify/launch-post.md\`), a \`# \` title first, then \`product: <Product>\`, so the Marketing page tags it. Its screenshots and clips go next to it.`,
126
226
  "- Every post opens with a strong hook (its first line written for reach), says plainly what the product is, and states its end goal. Where the end goal isn't written down, draft one and flag it in QUESTIONS.md for the user to confirm.",
127
- "- Never post, publish, schedule or send anything, and never sign in anywhere as the user: they approve each draft under Marketing and post it themselves. A post that waits on their OK is a question in QUESTIONS.md, with what to check first."];
227
+ `- Each draft holds the post apart from your notes: a \`# \` title, then \`product:\`, \`platform:\` (linkedin), \`when:\` (YYYY-MM-DD HH:MM, if it's scheduled) and \`media:\` (its picture or video, next to it) lines, then \`## Post\` with exactly the text that goes out (its line breaks, its hashtags) and nothing else, then \`## Notes\` for your reasoning, sources and anything for the user. Symbiot shows \`## Post\` as the platform's own preview, and what you post is that text, unchanged.`,
228
+ `- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`), with the approved text and media, then check it's there.`
229
+ + " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved."];
128
230
  }
129
231
 
130
- export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief };
232
+ export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia };
package/mind.mjs CHANGED
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { VERSION, CONFIG_DIR, clipWords } from "./core.mjs";
24
24
  import { write } from "./ai.mjs";
25
- import { handoffCmd, runHandoff } from "./agents.mjs";
25
+ import { handoffCmd, runHandoff, isUrgent, urgentFirst } from "./agents.mjs";
26
26
  import { addTask, pushTasks } from "./tasks.mjs";
27
27
  import { laneMap } from "./scan.mjs";
28
28
  import { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
@@ -178,13 +178,16 @@ function selfLane(map) {
178
178
  const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() === String(repo || "").trim().toLowerCase()) || "";
179
179
  // Do it, in the right lane: a repo's work joins its tasks and its agent starts
180
180
  // there, the way Send to repos does; anything else, an agent of its own (actNow).
181
- function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, now = Date.now() } = {}) {
181
+ // Urgent work (agents.mjs isUrgent) goes first: every other lane with work going
182
+ // parks, and the lane's own routine run stops, so this one starts now.
183
+ function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, first = urgentFirst, now = Date.now() } = {}) {
182
184
  const lane = laneName(repo, map);
183
185
  if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
184
186
  const t = add(request, lane); if (t && t.error) return { error: t.error };
185
187
  const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
188
+ const urgent = isUrgent(request) ? first(map[lane], { lanes: map }) : null;
186
189
  const e = run(map[lane]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
187
- return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
190
+ return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }), ...(urgent ? { urgent } : {}) };
188
191
  }
189
192
  // For later: on the list, in a repo's lane if it names one Symbiot knows.
190
193
  function taskIn(text, repo, { map = {}, add = addTask } = {}) {
@@ -230,6 +233,7 @@ async function converse({ where, role = "", context = "", history = "", question
230
233
  if (want && want.agent && act.agent) {
231
234
  did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
232
235
  reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
236
+ : did.lane && did.urgent ? `\n\n→ Urgent, so it goes first: handed to ${did.lane}'s agent${did.urgent.stopped ? `, which stopped what it was on (its changes stay) to start on this now` : did.queued ? " (it's already on urgent work, and takes this next)" : ""}. ${did.urgent.parked.length ? `Parked till it's done: ${did.urgent.parked.join(", ")} (a run already going there finishes first, then they wait). They pick up again in that order once it's done.` : "Nothing else was running to park."} It's on the Workdesk.`
233
237
  : did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's on the Workdesk.`
234
238
  : "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
235
239
  } else if (want && want.task && act.task) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.57.4",
3
+ "version": "0.57.8",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,7 +81,7 @@
81
81
  "@anthropic-ai/sdk": "^0.128.0"
82
82
  },
83
83
  "scripts": {
84
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
84
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
85
85
  "lint": "eslint .",
86
86
  "postinstall": "node postinstall.mjs"
87
87
  },
package/post.mjs CHANGED
@@ -9,9 +9,10 @@
9
9
  // - Your voice: example posts of yours in voice.md (Symbiot's config folder), a
10
10
  // line of --- between each. Without them it doesn't draft. Link LinkedIn and it
11
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.
12
+ // - Nothing is posted until you approve it, and then posting is the agent's job,
13
+ // not yours: Approve hands the post to Marketing's agent, which posts it through
14
+ // Symbiot's browser signed in to LinkedIn (LinkedIn has no posting route without
15
+ // a partner app), with exactly the text you approved, and checks it's there.
15
16
  // - Every action (drafted, edited, approved, skipped, dropped, replaced) is
16
17
  // appended to posts-log.jsonl, with the text, the date and the platform.
17
18
  // - Pictures and videos: each draft says in a line what would show it best
@@ -29,8 +30,10 @@ import { randomBytes } from "node:crypto";
29
30
  import { connect } from "node:net";
30
31
  import { CONFIG_DIR, sh, hasCmd, loadConfig } from "./core.mjs";
31
32
  import { resolveProvider, write } from "./ai.mjs";
32
- import { commits, discoveredRepos } from "./scan.mjs";
33
+ import { commits, discoveredRepos, laneMap } from "./scan.mjs";
33
34
  import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
35
+ import { runHandoff, runningHandoff } from "./agents.mjs";
36
+ import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames } from "./marketing.mjs";
34
37
 
35
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") };
36
39
  const PLATFORM = "linkedin";
@@ -300,17 +303,39 @@ function skipPost(id, { now = Date.now(), paths = PATHS } = {}) {
300
303
  w.p.status = "skipped"; w.p.skipped = now; savePosts(w.d, paths); logAction("skipped", w.p, now, paths);
301
304
  return { ok: true, post: w.p };
302
305
  }
303
- // Approve: your yes to this one post. It's logged, then copied to your clipboard
304
- // (`copy`: copyText in a terminal; the app's page copies it itself, so it passes
305
- // null) for you to paste into LinkedIn's share box (`share`), with its pictures or
306
- // video (`files`, in `folder`) to add there. Symbiot doesn't post or schedule it.
307
- function approvePost(id, { copy = copyText, now = Date.now(), paths = PATHS } = {}) {
306
+ // Approve: your yes to this one post, and your only step in it. It's logged, then
307
+ // handed to Marketing's agent (handToMarketing), which posts it through Symbiot's
308
+ // browser signed in to LinkedIn, pictures or video and all. Nothing for you to
309
+ // copy, paste or attach (that made posting your job: the last place that still did).
310
+ function approvePost(id, { hand = handToMarketing, now = Date.now(), paths = PATHS } = {}) {
308
311
  const w = waiting(id, paths); if (w.error) return w;
309
312
  w.p.status = "approved"; w.p.approved = now; savePosts(w.d, paths); logAction("approved", w.p, now, paths, mediaNote(w.p));
310
- const copied = copy ? copy(w.p.text) : "";
311
- const files = mediaFiles(w.p, paths), what = mediaWords(w.p.media);
312
- return { ok: true, post: w.p, copied, share: SHARE_URL, files, folder: files.length ? mediaDir(w.p.id, paths) : "",
313
- note: `Symbiot doesn't post or schedule it: paste it into LinkedIn's share box${what ? `, add its ${what} there (the photo or video button)` : ""} and post it yourself.` };
313
+ const h = hand(w.p, { now, paths }) || { error: "Marketing's agent wasn't given it." };
314
+ return { ok: true, post: w.p, handed: h, note: h.error ? `Approved, but it didn't reach Marketing's agent: ${h.error}` : h.said };
315
+ }
316
+ // The post, into Marketing's lane as an approved draft (drafts/<product>/post-<id>.md,
317
+ // the text under ## Post, unchanged, its pictures or video copied next to it, approved
318
+ // by its text as the Marketing page approves one), and its agent told in ANSWERS.md to
319
+ // post it, then started: now, or once the run there finishes. { rel, said, job?, queued? } or { error }.
320
+ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_DIR, run = runHandoff, running = runningHandoff, names } = {}) {
321
+ if (!ensureMarketing(dir)) return { error: "Couldn't make Marketing's folder." };
322
+ if (!names) { try { names = productNames(laneMap()); } catch { names = []; } }
323
+ const product = productOf(p.text, names), folder = (product.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "linkedin"), rel = `drafts/${folder}/post-${p.id}.md`;
324
+ const media = [];
325
+ try {
326
+ mkdirSync(join(dir, "drafts", folder), { recursive: true });
327
+ for (const m of p.media || []) { const name = `post-${p.id}-${m.file}`; writeFileSync(join(dir, "drafts", folder, name), readFileSync(join(mediaDir(p.id, paths), m.file))); media.push(name); }
328
+ writeFileSync(join(dir, rel), `# ${KIND_LABEL[p.kind] || "Post"}: approved under Drafts to post\n${product ? `product: ${product}\n` : ""}platform: ${PLATFORM}\n${media.length ? `media: ${media.join(", ")}\n` : ""}\n## Post\n${p.text}\n\n## Notes\nDrafted by Symbiot from the week's git and approved by the user on ${new Date(now).toISOString().slice(0, 10)}.${(p.sources || []).length ? `\nFrom git:\n${p.sources.map((x) => "- " + x).join("\n")}` : ""}\n`);
329
+ } catch (e) { return { error: "Couldn't write it into Marketing's folder: " + ((e && e.message) || e) }; }
330
+ const st = setDraftStatus(rel, "approved", { dir, now }); if (st.error) return st;
331
+ try {
332
+ const f = join(dir, ".symbiot", "ANSWERS.md"), had = existsSync(f) ? readFileSync(f, "utf8") : "# Answers\n";
333
+ writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
334
+ } catch (e) { return { error: "Couldn't tell Marketing's agent: " + ((e && e.message) || e) }; }
335
+ if (running(dir)) return { rel, queued: true, said: "Approved. Marketing's agent posts it on LinkedIn once the run there now finishes." };
336
+ const e = run(dir, { force: true });
337
+ return e && e.id && !e.blocked && !e.busy ? { rel, job: e.id, said: "Approved. Marketing's agent is posting it on LinkedIn now, through Symbiot's signed-in browser." }
338
+ : { rel, queued: true, said: `Approved. ${(e && e.note) || "Marketing's agent posts it once it starts: pick your coding agent in Settings → Handoff if it doesn't."}` };
314
339
  }
315
340
 
316
341
  // ---- pictures and videos -----------------------------------------------------------
@@ -319,8 +344,8 @@ function approvePost(id, { copy = copyText, now = Date.now(), paths = PATHS } =
319
344
  // browser, at twice the pixels), or a short clip of one (recorded there, scrolling
320
345
  // slowly down, and made an MP4 by ffmpeg). Each is a copy, kept yours only in
321
346
  // post-media/<post id>/ in Symbiot's config folder. Symbiot attaches nothing:
322
- // LinkedIn's share box can't be filled in from outside, so Approve gives you the
323
- // files (and the app opens their folder) to add there yourself.
347
+ // Approve hands them to Marketing's agent with the post, to attach in LinkedIn's
348
+ // share box through Symbiot's signed-in browser.
324
349
  const MEDIA = { png: ["picture", "image/png"], jpg: ["picture", "image/jpeg"], gif: ["picture", "image/gif"], mp4: ["video", "video/mp4"], mov: ["video", "video/quicktime"], webm: ["video", "video/webm"] };
325
350
  const MAX_PICTURES = 20, MAX_BYTES = { picture: 20 * 1024 * 1024, video: 500 * 1024 * 1024 };
326
351
  const NOT_MEDIA = "That isn't a picture or video LinkedIn takes: a PNG, JPG or GIF picture, or an MP4, MOV or WebM video.";
@@ -337,7 +362,6 @@ function mediaType(buf) {
337
362
  return "";
338
363
  }
339
364
  const mediaDir = (postId, paths = PATHS) => join(paths.media, String(postId).replace(/[^\w-]/g, ""));
340
- const mediaFiles = (p, paths = PATHS) => (p.media || []).map((m) => join(mediaDir(p.id, paths), m.file));
341
365
  // "2 pictures", "its video": what a post carries, in words ("" for nothing)
342
366
  function mediaWords(media = []) {
343
367
  const v = media.filter((m) => m.kind === "video").length, n = media.length - v;
@@ -587,5 +611,5 @@ async function testInstalls(t, { now = Date.now(), get = null } = {}) {
587
611
  }
588
612
  function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
589
613
 
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,
614
+ 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,
591
615
  mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
package/reports.mjs CHANGED
@@ -14,8 +14,9 @@
14
14
  // path; it's shown as HTML made here (mdHtml), with everything escaped first.
15
15
  //
16
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";
17
+ import { join, basename, dirname, resolve, extname, sep } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync, realpathSync } from "node:fs";
19
20
  import { createHash } from "node:crypto";
20
21
  import { CONFIG_DIR } from "./core.mjs";
21
22
  import { runFolders, laneOf, runTitle } from "./handback.mjs";
@@ -27,7 +28,7 @@ const NOT_REPORTS = /^(?:TASKS(?:\.[\w-]+)?|QUESTIONS|ANSWERS|HANDOFF|SKIPPED|CL
27
28
  const MAX_READ = 512 * 1024, MAX_LIST = 200;
28
29
  const idOf = (file) => createHash("sha1").update(file).digest("hex").slice(0, 12);
29
30
 
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 loadSeen(file = SEEN_FILE) { try { const d = JSON.parse(readFileSync(file, "utf8")), obj = (x) => (x && typeof x === "object" ? x : {}); return { seen: obj(d && d.seen), decided: obj(d && d.decided) }; } catch { return { seen: {}, decided: {} }; } }
31
32
  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
 
33
34
  // A report's title: its first "# " heading, without markdown; else its file name.
@@ -64,7 +65,8 @@ function readReport(id, { list, mark = true, seenFile = SEEN_FILE } = {}) {
64
65
  let text = ""; try { text = readFileSync(r.file, "utf8"); } catch (e) { return { error: `Couldn't read ${r.file}: ${e.message}` }; }
65
66
  const cut = text.length > MAX_READ; if (cut) text = text.slice(0, MAX_READ);
66
67
  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
+ const draft = isDraftReport(r.title, r.name);
69
+ return { ...r, new: false, text, html: mdHtml(text, { id: r.id, base: dirname(r.file), roots: imageRoots(r) }) + (cut ? "<p class='muted'>(cut at 512 KB: open the file for the rest)</p>" : ""), draft, ...(draft ? { decided: decidedOn(r, seenFile) } : { ideas: reportIdeas(text) }) };
68
70
  }
69
71
  // Mark them all read (the list's "Mark all read").
70
72
  function markAllRead({ list, seenFile = SEEN_FILE } = {}) {
@@ -75,38 +77,87 @@ function markAllRead({ list, seenFile = SEEN_FILE } = {}) {
75
77
  // ---- markdown, as safe HTML ----------------------------------------------------------
76
78
  // What reports use: headings, paragraphs, lists (numbered or not, nested by
77
79
  // 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
+ // links (http, https and mailto only), and images. Everything is escaped before any
81
+ // tag is made, so a report can't put its own HTML or script on the page.
82
+ //
83
+ // Images: the Steve LinkedIn previews put their card and composer shots in as
84
+ // ![alt](/home/…/x.png), and they showed as raw text (2026-10-08). A local image
85
+ // (absolute, ~/, or relative to the report) now shows in place, fetched by the
86
+ // report's id and that src (reportImage), never by a bare path, and only from
87
+ // ~/.config/symbiot, the report's own folder or your projects. One that isn't
88
+ // there says so. A web image stays a link: opening a report loads nothing remote.
89
+ // A ```text block (a draft post, say) wraps and keeps its line breaks.
80
90
  const escHtml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
81
- function inline(s) {
91
+ const unEsc = (s) => String(s).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
92
+ const IMG = /!\[([^\]]*)\]\s?\(\s*(&lt;.+?&gt;|[^\s)]+)(?:\s+&quot;[^]*?&quot;)?\s*\)/g; // on escaped text; a break between ] and ( is joined to one space
93
+ const IMG_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }; // no svg: it can carry script
94
+ // Where a report's image src points on disk ("" for a web one or nothing usable).
95
+ function imagePath(src, base) {
96
+ let s = String(src || "").trim().replace(/^<(.*)>$/, "$1");
97
+ if (/^(?:https?:|data:|mailto:)/i.test(s)) return "";
98
+ s = s.replace(/^file:\/\//i, ""); try { s = decodeURI(s); } catch {}
99
+ if (!s) return "";
100
+ if (s === "~" || s.startsWith("~/")) return join(homedir(), s.slice(1));
101
+ return resolve(base || ".", s);
102
+ }
103
+ // Folders a report's images may come from: Symbiot's own, the report's, your projects.
104
+ function imageRoots(r) { let lanes = []; try { lanes = Object.values(laneMap()); } catch {} return [CONFIG_DIR, r && r.folder, ...lanes].filter(Boolean); }
105
+ // The real file an image path is, if it's an image under one of roots; else why not.
106
+ function imageFile(p, roots) {
107
+ if (!p) return { error: "not a local image" };
108
+ if (!IMG_TYPES[extname(p).toLowerCase()]) return { error: "not an image file" };
109
+ let real; try { real = realpathSync(p); if (!statSync(real).isFile()) throw 0; } catch { return { error: "missing", missing: true }; }
110
+ const ok = (roots || []).some((r) => { let rr; try { rr = realpathSync(r); } catch { return false; } return real === rr || real.startsWith(rr.endsWith(sep) ? rr : rr + sep); });
111
+ return ok ? { file: real, type: IMG_TYPES[extname(real).toLowerCase()] } : { error: "outside your project folders" };
112
+ }
113
+ function image(alt, src, o) {
114
+ const raw = unEsc(src).replace(/^<(.*)>$/, "$1"), p = imagePath(raw, o.base);
115
+ if (!p && /^https?:\/\//i.test(raw)) return `<a href="${escHtml(raw)}" target="_blank" rel="noopener noreferrer">${alt || "image"}</a>`;
116
+ const f = o.id ? imageFile(p, o.roots) : { error: "no report to fetch it by" };
117
+ if (f.error) return `<span class="rimgmiss" title="${escHtml(raw)}">🖼 Image ${f.missing ? "missing" : "not shown (" + f.error + ")"}: ${alt ? alt + " · " : ""}<code>${escHtml(raw)}</code></span>`;
118
+ const url = `/api/reports/image?id=${encodeURIComponent(o.id)}&src=${encodeURIComponent(raw)}`;
119
+ return `<img class="rimg" data-src="${escHtml(url)}" alt="${alt}" title="${alt ? alt + " · " : ""}click to enlarge" loading="lazy">`;
120
+ }
121
+ // A report's image, for its <img>: only a src the report itself shows, only under its roots.
122
+ function reportImage(id, src, { list, roots } = {}) {
123
+ const r = (list || listReports()).find((x) => x.id === String(id || ""));
124
+ if (!r) return { error: "No report by that id." };
125
+ let text = ""; try { text = readFileSync(r.file, "utf8").slice(0, MAX_READ); } catch { return { error: "Couldn't read the report." }; }
126
+ const want = String(src || ""), seen = [...escHtml(text.replace(/\r?\n/g, " ")).matchAll(IMG)].some((m) => unEsc(m[2]).replace(/^<(.*)>$/, "$1") === want);
127
+ if (!seen) return { error: "That image isn't in the report." };
128
+ return imageFile(imagePath(want, dirname(r.file)), roots || imageRoots(r));
129
+ }
130
+ function inline(s, o = {}) {
82
131
  const code = [];
83
132
  let t = escHtml(s).replace(/`([^`]+)`/g, (_, c) => { code.push(c); return `\u0000${code.length - 1}\u0000`; });
133
+ const imgs = []; t = t.replace(IMG, (_, alt, src) => { imgs.push(image(alt, src, o)); return `\u0001${imgs.length - 1}\u0001`; });
84
134
  t = t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (_, txt, url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${txt}</a>`)
85
135
  .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/__([^_]+)__/g, "<b>$1</b>")
86
136
  .replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, "$1<i>$2</i>").replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:!?]|$)/g, "$1<i>$2</i>")
87
137
  .replace(/~~([^~]+)~~/g, "<s>$1</s>");
88
- return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${code[+i]}</code>`);
138
+ return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${code[+i]}</code>`).replace(/\u0001(\d+)\u0001/g, (_, i) => imgs[+i]);
89
139
  }
90
140
  const cells = (line) => line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
91
- function mdHtml(md) {
141
+ const PROSE = /^(?:text|txt|plain|prose|post|linkedin|markdown|md)$/i; // a fence holding writing, not code
142
+ function mdHtml(md, o = {}) {
92
143
  const lines = String(md || "").replace(/\r\n?/g, "\n").split("\n"), out = [];
93
144
  let i = 0, para = [];
94
- const flush = () => { if (para.length) { out.push(`<p>${inline(para.join(" "))}</p>`); para = []; } };
145
+ const flush = () => { if (para.length) { out.push(`<p>${inline(para.join(" "), o)}</p>`); para = []; } };
95
146
  while (i < lines.length) {
96
147
  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; }
148
+ const fence = l.match(/^\s*(```|~~~)\s*([\w+-]*)/);
149
+ if (fence) { flush(); const body = []; i++; while (i < lines.length && !lines[i].trim().startsWith(fence[1])) body.push(lines[i++]); i++; out.push(`<pre${PROSE.test(fence[2]) ? ' class="prose"' : ""}><code>${escHtml(body.join("\n"))}</code></pre>`); continue; }
99
150
  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; }
151
+ if (h) { flush(); const n = Math.min(h[1].length + 1, 6); out.push(`<h${n}>${inline(h[2], o)}</h${n}>`); i++; continue; }
101
152
  if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push("<hr>"); i++; continue; }
102
153
  if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
103
154
  flush(); const head = cells(l); i += 2; const rows = [];
104
155
  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>`);
156
+ out.push(`<div class="rtable"><table><thead><tr>${head.map((c) => `<th>${inline(c, o)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c, o)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
106
157
  continue;
107
158
  }
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; }
159
+ 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"), o)}</blockquote>`); continue; }
160
+ if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(l)) { flush(); i = list(lines, i, out, o); continue; }
110
161
  if (!l.trim()) { flush(); i++; continue; }
111
162
  para.push(l.trim()); i++;
112
163
  }
@@ -114,7 +165,7 @@ function mdHtml(md) {
114
165
  return out.join("\n");
115
166
  }
116
167
  // A list from line i, its items' deeper lines as nested lists; gives the next line.
117
- function list(lines, i, out) {
168
+ function list(lines, i, out, o) {
118
169
  const ind = (l) => l.match(/^\s*/)[0].replace(/\t/g, " ").length, base = ind(lines[i]);
119
170
  const ordered = /^\s*\d+[.)]\s/.test(lines[i]), items = [];
120
171
  while (i < lines.length) {
@@ -122,15 +173,46 @@ function list(lines, i, out) {
122
173
  if (!l.trim()) { if (i + 1 < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[i + 1]) && ind(lines[i + 1]) >= base) { i++; continue; } break; }
123
174
  const m = l.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/), d = ind(l);
124
175
  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; }
176
+ if (d > base && items.length) { if (m) { const sub = []; i = list(lines, i, sub, o); items[items.length - 1].sub.push(...sub); } else { items[items.length - 1].text += " " + l.trim(); i++; } continue; }
126
177
  break;
127
178
  }
128
179
  const tag = ordered ? "ol" : "ul";
129
- out.push(`<${tag}>${items.map((it) => `<li>${inline(it.text)}${it.sub.join("")}</li>`).join("")}</${tag}>`);
180
+ out.push(`<${tag}>${items.map((it) => `<li>${inline(it.text, o)}${it.sub.join("")}</li>`).join("")}</${tag}>`);
130
181
  return i;
131
182
  }
132
183
 
184
+ // ---- what to do with it --------------------------------------------------------------
185
+ // Garth read the "Argena catch-up" report, then copied its "Top 3 next" into Home's chat
186
+ // by hand to act on it (2026-10-08): the report gave him nothing to act on. Its end now
187
+ // offers 2-4 ideas drawn from it, each one tick from the Workdesk in its lane, and a
188
+ // box to ask about it in place (home.mjs reportIdeasAdd, reportAsk). A draft for the
189
+ // user's OK (a post preview, say) gets Approve and Reject instead.
190
+ const NEXT_HEAD = /\b(?:next|top \d+|recommend\w*|suggest\w*|ideas?|actions?|action items?|to ?dos?|follow[- ]?ups?|what to do|priorit\w*|fix(?:es)?|plan)\b/i;
191
+ const IDEAS_MAX = 4;
192
+ const cleanIdea = (s) => String(s).replace(/^\[[ xX]\]\s+/, "").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/\*\*|__|`/g, "").replace(/\s+/g, " ").trim().slice(0, 300);
193
+ // The top-level items of its "next" sections ("## Top 3 next", "## Recommendations"…),
194
+ // first sections first, at most IDEAS_MAX. None when it has no such section.
195
+ function reportIdeas(text, max = IDEAS_MAX) {
196
+ const out = []; let on = false, base = -1;
197
+ for (const l of String(text || "").replace(/\r\n?/g, "\n").split("\n")) {
198
+ const h = l.match(/^#{1,6}\s+(.*)$/) || l.match(/^\s*\*\*([^*]+)\*\*:?\s*$/);
199
+ if (h) { on = NEXT_HEAD.test(h[1]); base = -1; continue; }
200
+ if (!on) continue;
201
+ const m = l.match(/^(\s*)(?:[-*+]|\d+[.)])\s+(.+)$/); if (!m) continue;
202
+ const ind = m[1].replace(/\t/g, " ").length; if (base < 0) base = ind; if (ind > base) continue;
203
+ const t = cleanIdea(m[2]); if (t && !out.some((x) => x.toLowerCase() === t.toLowerCase())) out.push(t);
204
+ if (out.length >= max) break;
205
+ }
206
+ return out;
207
+ }
208
+ // A draft waiting on the user's OK (its title or file says draft or preview): Approve and
209
+ // Reject rather than ideas.
210
+ const isDraftReport = (title, name) => /\bdrafts?\b|\bpreviews?\b/i.test(`${title} ${String(name || "").replace(/[-_.]+/g, " ")}`);
211
+ // What the user decided on a draft report, kept by its mtime (a changed draft asks again).
212
+ function decidedOn(r, file = SEEN_FILE) { const d = loadSeen(file).decided[r.id]; return d && d.mtime === r.mtime ? d.status : ""; }
213
+ function decide(r, status, file = SEEN_FILE) { const d = loadSeen(file); d.decided[r.id] = { status, mtime: r.mtime, at: Date.now() }; return saveSeen(d, file); }
214
+
133
215
  // How many reports are new, and the newest one's title (Home's droplet).
134
216
  function reportsNews(opts) { const l = listReports(opts).filter((r) => r.new); return { count: l.length, latest: l[0] ? l[0].title : "" }; }
135
217
 
136
- export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, markAllRead, reportsNews, mdHtml };
218
+ export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, reportImage, imagePath, markAllRead, reportsNews, mdHtml, reportIdeas, isDraftReport, decidedOn, decide, IDEAS_MAX };