symbiot 0.57.3 → 0.57.6
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 +26 -0
- package/README.md +41 -9
- package/agents.mjs +116 -8
- package/handover.mjs +86 -4
- package/home.mjs +226 -25
- package/index.mjs +7 -11
- package/lanes.mjs +49 -6
- package/marketing.mjs +110 -8
- package/mind.mjs +7 -3
- package/package.json +2 -2
- package/post.mjs +41 -17
- package/reports.mjs +102 -20
- package/server.mjs +39 -14
- package/tasks.mjs +96 -29
- package/ui.mjs +141 -54
- package/watch.mjs +110 -11
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 {
|
|
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}
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.57.6",
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
function approvePost(id, {
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
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 {
|
|
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"))
|
|
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
|
-
|
|
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
|
|
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
|
+
// , 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
81
|
-
|
|
91
|
+
const unEsc = (s) => String(s).replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
92
|
+
const IMG = /!\[([^\]]*)\]\s?\(\s*(<.+?>|[^\s)]+)(?:\s+"[^]*?")?\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
|
-
|
|
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 };
|