pressclaw 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1883 @@
1
+ // index.ts
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ var STYLES = `
6
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; max-width: 680px; margin: 48px auto; padding: 0 24px; line-height: 1.7; color: #1a1a1a; }
7
+ h1 { font-size: 1.8em; margin-bottom: 0.3em; line-height: 1.2; }
8
+ h2 { font-size: 1.4em; margin-top: 1.8em; }
9
+ h3 { font-size: 1.15em; margin-top: 1.5em; }
10
+ p { margin: 1em 0; }
11
+ a { color: #0b5fff; text-decoration: none; }
12
+ a:hover { text-decoration: underline; }
13
+ strong { font-weight: 600; }
14
+ code { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 0.88em; background: #f4f4f5; padding: 2px 6px; border-radius: 4px; }
15
+ pre { background: #1e1e2e; color: #cdd6f4; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 1.5em 0; line-height: 1.5; }
16
+ pre code { background: none; padding: 0; color: inherit; font-size: 0.85em; }
17
+ blockquote { border-left: 3px solid #d4d4d8; margin: 1.5em 0; padding: 0.5em 1em; color: #52525b; }
18
+ ul { padding-left: 1.5em; margin: 1em 0; }
19
+ li { margin: 0.3em 0; }
20
+ hr { border: none; border-top: 1px solid #e4e4e7; margin: 2em 0; }
21
+ .meta { color: #71717a; font-size: 0.9em; margin-bottom: 2em; }
22
+ footer { margin-top: 3em; padding-top: 1.5em; border-top: 1px solid #e4e4e7; color: #71717a; font-size: 0.85em; }
23
+ footer a { color: #71717a; }
24
+ `;
25
+ var DEFAULT_TEMPLATE = (title, body, meta) => `<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8" />
29
+ <title>${title} \u2014 ${meta.siteTitle}</title>
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <style>${STYLES}</style>
32
+ </head>
33
+ <body>
34
+ <h1>${title}</h1>
35
+ ${meta.date ? `<div class="meta">${meta.date}${meta.authorName ? ` \xB7 ${meta.authorName}` : ""}</div>` : ""}
36
+ <article>${body}</article>
37
+ <footer><a href="../">\u2190 ${meta.siteTitle}</a></footer>
38
+ </body>
39
+ </html>`;
40
+ var INDEX_TEMPLATE = (items, meta) => `<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <title>${meta.siteTitle}</title>
45
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <style>${STYLES} .post-list { list-style: none; padding: 0; } .post-list li { margin: 1.5em 0; } .post-list .date { color: #71717a; font-size: 0.85em; } .post-list .excerpt { color: #52525b; font-size: 0.95em; margin-top: 0.3em; }</style>
47
+ </head>
48
+ <body>
49
+ <h1>${meta.siteTitle}</h1>
50
+ <ul class="post-list">
51
+ ${items.map((i) => `<li><a href="${i.url}">${i.title}</a><div class="date">${i.date}</div><div class="excerpt">${i.excerpt}</div></li>`).join("\n")}
52
+ </ul>
53
+ </body>
54
+ </html>`;
55
+ var rssTemplate = (items, meta) => `<?xml version="1.0" encoding="UTF-8"?>
56
+ <rss version="2.0">
57
+ <channel>
58
+ <title>${meta.siteTitle}</title>
59
+ <link>${meta.baseUrl ?? ""}</link>
60
+ <description>${meta.siteTitle}</description>
61
+ ${items.map((i) => `<item><title>${i.title}</title><link>${i.url}</link><pubDate>${i.date}</pubDate><description><![CDATA[${i.excerpt}]]></description></item>`).join("\n")}
62
+ </channel>
63
+ </rss>`;
64
+ function resolveWorkspace(api) {
65
+ return api.config?.agents?.defaults?.workspace || process.env.HOME + "/.openclaw/workspace";
66
+ }
67
+ function resolveConfig(api) {
68
+ const cfg = api.config.plugins?.entries?.["public-thinking"]?.config ?? {};
69
+ const workspace = resolveWorkspace(api);
70
+ return {
71
+ notesDir: cfg.notesDir || path.join(workspace, "notes"),
72
+ outputDir: cfg.outputDir || path.join(workspace, "public"),
73
+ publicPath: cfg.publicPath || "/public",
74
+ siteTitle: cfg.siteTitle || "Your Thinking",
75
+ authorName: cfg.authorName || "",
76
+ baseUrl: cfg.baseUrl || "",
77
+ dailyPrompt: {
78
+ enabled: cfg.dailyPrompt?.enabled ?? true,
79
+ schedule: cfg.dailyPrompt?.schedule || "0 10 * * *",
80
+ timezone: cfg.dailyPrompt?.timezone || "UTC",
81
+ prompt: cfg.dailyPrompt?.prompt || "Review yesterday's conversations and daily notes. Identify any interesting ideas, insights, or learnings worth sharing publicly. If you find something compelling, draft a short note (3-8 paragraphs) that distills the idea for a general audience. Use `openclaw notes new \"<title>\"` to create it, then write the content. Don't publish yet \u2014 just draft. If nothing stands out, that's fine \u2014 skip today."
82
+ }
83
+ };
84
+ }
85
+ function ensureDirs(outputDir) {
86
+ fs.mkdirSync(outputDir, { recursive: true });
87
+ fs.mkdirSync(path.join(outputDir, "posts"), { recursive: true });
88
+ }
89
+ function parseFrontMatter(content) {
90
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
91
+ if (!match) return { meta: {}, body: content };
92
+ const metaLines = match[1].split("\n");
93
+ const meta = {};
94
+ for (const line of metaLines) {
95
+ const [key, ...rest] = line.split(":");
96
+ if (!key) continue;
97
+ meta[key.trim()] = rest.join(":").trim().replace(/^"|"$/g, "");
98
+ }
99
+ return { meta, body: match[2].trim() };
100
+ }
101
+ function slugify(input) {
102
+ return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
103
+ }
104
+ function listNotes(notesDir) {
105
+ if (!fs.existsSync(notesDir)) return [];
106
+ return fs.readdirSync(notesDir).filter((f) => f.endsWith(".md"));
107
+ }
108
+ function estimateSyllables(word) {
109
+ word = word.toLowerCase().replace(/[^a-z]/g, "");
110
+ if (word.length <= 3) return 1;
111
+ word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "");
112
+ word = word.replace(/^y/, "");
113
+ const vowelGroups = word.match(/[aeiouy]{1,2}/g);
114
+ return vowelGroups ? vowelGroups.length : 1;
115
+ }
116
+ function extractStyleMarkers(body) {
117
+ const text = body.replace(/^#+\s+.*/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`[^`]+`/g, "CODE").replace(/```[\s\S]*?```/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/^>\s+/gm, "").replace(/^[-*]\s+/gm, "").replace(/^\d+\.\s+/gm, "").trim();
118
+ const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter((s) => s.length > 3);
119
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
120
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
121
+ const avgSentenceLength = sentences.length > 0 ? Math.round(words.length / sentences.length * 10) / 10 : 0;
122
+ const avgParagraphSentences = paragraphs.length > 0 ? Math.round(sentences.length / paragraphs.length * 10) / 10 : 0;
123
+ const firstPerson = (text.match(/\b(I|I've|I'm|my|me|we|we've|our)\b/gi) || []).length;
124
+ const secondPerson = (text.match(/\b(you|your|you're|you've)\b/gi) || []).length;
125
+ const perspective = firstPerson > secondPerson * 2 ? "first-person" : secondPerson > firstPerson * 2 ? "second-person" : "mixed";
126
+ const emojiCount = (text.match(/[\u{1F300}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}]/gu) || []).length;
127
+ const emojiUsage = emojiCount === 0 ? "none" : emojiCount < 3 ? "rare" : "frequent";
128
+ const syllableCount = words.reduce((sum, w) => sum + estimateSyllables(w), 0);
129
+ const readability = Math.round(
130
+ 206.835 - 1.015 * (words.length / Math.max(sentences.length, 1)) - 84.6 * (syllableCount / Math.max(words.length, 1))
131
+ );
132
+ return {
133
+ sentences: sentences.length,
134
+ paragraphs: paragraphs.length,
135
+ wordCount: words.length,
136
+ avgSentenceLength,
137
+ avgParagraphSentences,
138
+ perspective,
139
+ emojiUsage,
140
+ readabilityScore: Math.max(0, Math.min(100, readability))
141
+ };
142
+ }
143
+ function writeFrontmatterWithMarkers(meta, body, markers) {
144
+ const lines = ["---"];
145
+ const ordered = ["title", "slug", "status", "published_at", "publish_reason", "topic_id", "input_type", "tone", "structure", "confidence"];
146
+ const written = /* @__PURE__ */ new Set();
147
+ for (const key of ordered) {
148
+ if (meta[key] !== void 0 && meta[key] !== null) {
149
+ const val = meta[key];
150
+ if (key === "title" || key === "slug" || key === "publish_reason" || key === "topic_id" || key === "input_type") {
151
+ lines.push(`${key}: "${val}"`);
152
+ } else {
153
+ lines.push(`${key}: ${val}`);
154
+ }
155
+ written.add(key);
156
+ }
157
+ }
158
+ if (meta.tags) {
159
+ lines.push(`tags: ${typeof meta.tags === "string" ? meta.tags : JSON.stringify(meta.tags)}`);
160
+ written.add("tags");
161
+ }
162
+ lines.push(`style_markers: ${JSON.stringify(markers)}`);
163
+ written.add("style_markers");
164
+ for (const [key, val] of Object.entries(meta)) {
165
+ if (written.has(key)) continue;
166
+ if (key === "style_markers") continue;
167
+ lines.push(`${key}: ${val}`);
168
+ }
169
+ lines.push("---");
170
+ return lines.join("\n") + "\n\n" + body + "\n";
171
+ }
172
+ function updateAggregateProfile(notesDir) {
173
+ const files = listNotes(notesDir);
174
+ const now = /* @__PURE__ */ new Date();
175
+ const noteData = [];
176
+ for (const f of files) {
177
+ const full = fs.readFileSync(path.join(notesDir, f), "utf8");
178
+ const { meta, body } = parseFrontMatter(full);
179
+ if (meta.status !== "public" && meta.status !== "refined") continue;
180
+ const slug = f.replace(/\.md$/, "");
181
+ let markers;
182
+ if (meta.style_markers) {
183
+ try {
184
+ markers = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers;
185
+ } catch {
186
+ markers = extractStyleMarkers(body);
187
+ }
188
+ } else {
189
+ markers = extractStyleMarkers(body);
190
+ }
191
+ const pubDate = meta.published_at ? new Date(meta.published_at) : now;
192
+ const daysSince = Math.max(0, (now.getTime() - pubDate.getTime()) / (1e3 * 60 * 60 * 24));
193
+ const weight = 1 / (1 + daysSince * 0.01);
194
+ noteData.push({
195
+ slug,
196
+ markers,
197
+ publishedAt: meta.published_at || null,
198
+ weight
199
+ });
200
+ }
201
+ if (noteData.length === 0) return { noteCount: 0, updated: false };
202
+ const totalWeight = noteData.reduce((s, n) => s + n.weight, 0);
203
+ const wavg = (field) => {
204
+ const sum = noteData.reduce((s, n) => s + (n.markers[field] || 0) * n.weight, 0);
205
+ return Math.round(sum / totalWeight * 10) / 10;
206
+ };
207
+ const aggAvgSentenceLength = wavg("avgSentenceLength");
208
+ const aggAvgWordCount = wavg("wordCount");
209
+ const aggAvgReadability = wavg("readabilityScore");
210
+ const aggAvgParagraphSentences = wavg("avgParagraphSentences");
211
+ const perspectiveCounts = {};
212
+ for (const n of noteData) {
213
+ const p = n.markers.perspective || "mixed";
214
+ perspectiveCounts[p] = (perspectiveCounts[p] || 0) + n.weight;
215
+ }
216
+ const aggPerspective = Object.entries(perspectiveCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "mixed";
217
+ const emojiCounts = {};
218
+ for (const n of noteData) {
219
+ const e = n.markers.emojiUsage || "none";
220
+ emojiCounts[e] = (emojiCounts[e] || 0) + n.weight;
221
+ }
222
+ const aggEmoji = Object.entries(emojiCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "none";
223
+ const slCat = aggAvgSentenceLength < 12 ? "short" : aggAvgSentenceLength > 20 ? "long" : "medium";
224
+ const plCat = aggAvgParagraphSentences < 3 ? "short" : aggAvgParagraphSentences > 5 ? "long" : "medium";
225
+ const perNoteMarkers = {};
226
+ for (const n of noteData) {
227
+ perNoteMarkers[n.slug] = {
228
+ ...n.markers,
229
+ weight: Math.round(n.weight * 1e3) / 1e3,
230
+ publishedAt: n.publishedAt
231
+ };
232
+ }
233
+ const profilePath = path.join(notesDir, ".style-profile.json");
234
+ let existing = {};
235
+ if (fs.existsSync(profilePath)) {
236
+ try {
237
+ existing = JSON.parse(fs.readFileSync(profilePath, "utf8"));
238
+ } catch {
239
+ }
240
+ }
241
+ const evolution = existing.evolution || [];
242
+ const todayStr = now.toISOString().slice(0, 10);
243
+ const lastEvolution = evolution[evolution.length - 1];
244
+ if (!lastEvolution || lastEvolution.date !== todayStr) {
245
+ evolution.push({
246
+ date: todayStr,
247
+ noteCount: noteData.length,
248
+ avgSentenceLength: aggAvgSentenceLength,
249
+ avgWordCount: aggAvgWordCount,
250
+ avgReadability: aggAvgReadability
251
+ });
252
+ while (evolution.length > 50) evolution.shift();
253
+ } else {
254
+ lastEvolution.noteCount = noteData.length;
255
+ lastEvolution.avgSentenceLength = aggAvgSentenceLength;
256
+ lastEvolution.avgWordCount = aggAvgWordCount;
257
+ lastEvolution.avgReadability = aggAvgReadability;
258
+ }
259
+ const updated = {
260
+ updated: now.toISOString(),
261
+ analyzedNotes: noteData.map((n) => n.slug),
262
+ noteCount: noteData.length,
263
+ markers: {
264
+ avgSentenceLength: slCat,
265
+ paragraphLength: plCat,
266
+ emojiUsage: aggEmoji,
267
+ perspective: aggPerspective,
268
+ // Preserve existing qualitative markers
269
+ ...existing.markers?.vocabulary ? { vocabulary: existing.markers.vocabulary } : {},
270
+ ...existing.markers?.toneDefault ? { toneDefault: existing.markers.toneDefault } : {},
271
+ ...existing.markers?.openingStyle ? { openingStyle: existing.markers.openingStyle } : {},
272
+ ...existing.markers?.closingStyle ? { closingStyle: existing.markers.closingStyle } : {}
273
+ },
274
+ perNoteMarkers,
275
+ evolution,
276
+ // Preserve qualitative fields from existing profile
277
+ voiceDescription: existing.voiceDescription || "",
278
+ avoid: existing.avoid || [],
279
+ examples: existing.examples || {}
280
+ };
281
+ fs.writeFileSync(profilePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
282
+ return { noteCount: noteData.length, updated: true };
283
+ }
284
+ function loadStructureTemplates(pluginDir) {
285
+ const p = path.join(pluginDir, "templates", "structures.json");
286
+ if (!fs.existsSync(p)) return [];
287
+ try {
288
+ return JSON.parse(fs.readFileSync(p, "utf8"));
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+ function getStructureTemplate(pluginDir, name) {
294
+ return loadStructureTemplates(pluginDir).find((t) => t.name === name);
295
+ }
296
+ function loadStyleProfile(notesDir) {
297
+ const p = path.join(notesDir, ".style-profile.json");
298
+ if (!fs.existsSync(p)) return null;
299
+ try {
300
+ return JSON.parse(fs.readFileSync(p, "utf8"));
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+ function variationsDir(notesDir, slug) {
306
+ return path.join(notesDir, ".variations", slug);
307
+ }
308
+ function loadManifest(notesDir, slug) {
309
+ const p = path.join(variationsDir(notesDir, slug), "_manifest.json");
310
+ if (!fs.existsSync(p)) return null;
311
+ try {
312
+ return JSON.parse(fs.readFileSync(p, "utf8"));
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+ function loadTestResults(notesDir, slug) {
318
+ const p = path.join(variationsDir(notesDir, slug), "_test-results.json");
319
+ if (!fs.existsSync(p)) return null;
320
+ try {
321
+ return JSON.parse(fs.readFileSync(p, "utf8"));
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ function loadPersonas(notesDir) {
327
+ const p = path.join(notesDir, ".personas.json");
328
+ if (!fs.existsSync(p)) return null;
329
+ try {
330
+ return JSON.parse(fs.readFileSync(p, "utf8"));
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+ function topicsPath(notesDir) {
336
+ return path.join(notesDir, "topics.json");
337
+ }
338
+ function readTopics(notesDir) {
339
+ const p = topicsPath(notesDir);
340
+ if (!fs.existsSync(p)) return [];
341
+ try {
342
+ return JSON.parse(fs.readFileSync(p, "utf8"));
343
+ } catch {
344
+ return [];
345
+ }
346
+ }
347
+ function writeTopics(notesDir, topics) {
348
+ fs.mkdirSync(notesDir, { recursive: true });
349
+ fs.writeFileSync(topicsPath(notesDir), JSON.stringify(topics, null, 2) + "\n", "utf8");
350
+ }
351
+ function generateId() {
352
+ return Math.random().toString(36).slice(2, 10);
353
+ }
354
+ function findTopicByIdOrTitle(topics, query) {
355
+ const byId = topics.find((t) => t.id === query);
356
+ if (byId) return byId;
357
+ const lower = query.toLowerCase();
358
+ return topics.find((t) => t.title.toLowerCase() === lower) || topics.find((t) => t.title.toLowerCase().startsWith(lower));
359
+ }
360
+ function syncTopicStatus(notesDir, topics) {
361
+ let changed = false;
362
+ for (const topic of topics) {
363
+ if (!topic.slug) continue;
364
+ const noteFile = path.join(notesDir, `${topic.slug}.md`);
365
+ if (!fs.existsSync(noteFile)) continue;
366
+ const { meta } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
367
+ let noteStatus;
368
+ if (meta.status === "public") noteStatus = "published";
369
+ else if (meta.status === "refined") noteStatus = "refined";
370
+ else noteStatus = "drafted";
371
+ if (topic.status !== noteStatus) {
372
+ topic.status = noteStatus;
373
+ changed = true;
374
+ }
375
+ }
376
+ if (changed) writeTopics(notesDir, topics);
377
+ return topics;
378
+ }
379
+ function uniqueSlug(base, notesDir) {
380
+ let slug = base;
381
+ let i = 2;
382
+ while (fs.existsSync(path.join(notesDir, `${slug}.md`))) {
383
+ slug = `${base}-${i}`;
384
+ i += 1;
385
+ }
386
+ return slug;
387
+ }
388
+ function escapeHtml(input) {
389
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
390
+ }
391
+ function renderInline(text) {
392
+ text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
393
+ text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
394
+ text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, "<em>$1</em>");
395
+ text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, "<em>$1</em>");
396
+ text = text.replace(/`([^`]+?)`/g, "<code>$1</code>");
397
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
398
+ return text;
399
+ }
400
+ function renderMarkdown(md) {
401
+ const rawLines = md.split("\n");
402
+ const blocks = [];
403
+ let para = [];
404
+ let inCodeBlock = false;
405
+ let codeLines = [];
406
+ let codeLang = "";
407
+ const flushPara = () => {
408
+ if (para.length === 0) return;
409
+ blocks.push(`<p>${para.map(renderInline).join(" ")}</p>`);
410
+ para = [];
411
+ };
412
+ for (const rawLine of rawLines) {
413
+ if (/^```/.test(rawLine)) {
414
+ if (!inCodeBlock) {
415
+ flushPara();
416
+ inCodeBlock = true;
417
+ codeLang = rawLine.replace(/^```/, "").trim();
418
+ codeLines = [];
419
+ } else {
420
+ blocks.push(`<pre><code${codeLang ? ` class="language-${escapeHtml(codeLang)}"` : ""}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
421
+ inCodeBlock = false;
422
+ codeLines = [];
423
+ codeLang = "";
424
+ }
425
+ continue;
426
+ }
427
+ if (inCodeBlock) {
428
+ codeLines.push(rawLine);
429
+ continue;
430
+ }
431
+ const line = rawLine;
432
+ if (/^---+\s*$/.test(line)) {
433
+ flushPara();
434
+ blocks.push("<hr/>");
435
+ continue;
436
+ }
437
+ if (/^###\s+/.test(line)) {
438
+ flushPara();
439
+ blocks.push(`<h3>${renderInline(escapeHtml(line.replace(/^###\s+/, "")))}</h3>`);
440
+ continue;
441
+ }
442
+ if (/^##\s+/.test(line)) {
443
+ flushPara();
444
+ blocks.push(`<h2>${renderInline(escapeHtml(line.replace(/^##\s+/, "")))}</h2>`);
445
+ continue;
446
+ }
447
+ if (/^#\s+/.test(line)) {
448
+ flushPara();
449
+ blocks.push(`<h1>${renderInline(escapeHtml(line.replace(/^#\s+/, "")))}</h1>`);
450
+ continue;
451
+ }
452
+ if (/^[\-\*]\s+/.test(line)) {
453
+ flushPara();
454
+ blocks.push(`<li>${renderInline(escapeHtml(line.replace(/^[\-\*]\s+/, "")))}</li>`);
455
+ continue;
456
+ }
457
+ if (/^>\s*/.test(line)) {
458
+ flushPara();
459
+ blocks.push(`<blockquote><p>${renderInline(escapeHtml(line.replace(/^>\s*/, "")))}</p></blockquote>`);
460
+ continue;
461
+ }
462
+ if (line.trim() === "") {
463
+ flushPara();
464
+ continue;
465
+ }
466
+ para.push(escapeHtml(line));
467
+ }
468
+ flushPara();
469
+ let html = blocks.join("\n");
470
+ html = html.replace(/(<li>.*?<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
471
+ return html;
472
+ }
473
+ function excerptFrom(body) {
474
+ return body.replace(/[#*_`>\[\]]/g, "").split("\n").filter((l) => l.trim()).slice(0, 2).join(" ").slice(0, 180);
475
+ }
476
+ async function confirmPublishPrompt(title, excerpt) {
477
+ if (!process.stdin.isTTY) {
478
+ throw new Error("Publish requires confirmation; use --yes in non-interactive mode.");
479
+ }
480
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
481
+ const answer = await rl.question(`Publish "${title}"? (y/N)
482
+ ${excerpt}
483
+ > `);
484
+ if (!(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes")) {
485
+ rl.close();
486
+ return { ok: false };
487
+ }
488
+ const reason = await rl.question("Why publish this? (optional)\n> ");
489
+ rl.close();
490
+ return { ok: true, reason: reason.trim() };
491
+ }
492
+ function buildPublic({ notesDir, outputDir, publicPath, siteTitle, authorName, baseUrl }) {
493
+ ensureDirs(outputDir);
494
+ const files = listNotes(notesDir);
495
+ const items = [];
496
+ for (const file of files) {
497
+ const full = fs.readFileSync(path.join(notesDir, file), "utf8");
498
+ const { meta, body } = parseFrontMatter(full);
499
+ if (meta.status !== "public") continue;
500
+ const title = meta.title || file.replace(/\.md$/, "");
501
+ const slug = meta.slug || slugify(title);
502
+ const date = meta.published_at || (/* @__PURE__ */ new Date()).toUTCString();
503
+ const excerpt = excerptFrom(body);
504
+ const url = `${publicPath}/${slug}`;
505
+ const bodyHtml = renderMarkdown(body);
506
+ items.push({ title, url, date, excerpt, slug, bodyHtml });
507
+ const html = DEFAULT_TEMPLATE(title, bodyHtml, { siteTitle, authorName, date });
508
+ fs.writeFileSync(path.join(outputDir, "posts", `${slug}.html`), html, "utf8");
509
+ }
510
+ const indexHtml = INDEX_TEMPLATE(items, { siteTitle });
511
+ fs.writeFileSync(path.join(outputDir, "index.html"), indexHtml, "utf8");
512
+ const rssItems = items.map((i) => ({
513
+ ...i,
514
+ url: baseUrl ? `${baseUrl}${i.url}` : i.url
515
+ }));
516
+ const rss = rssTemplate(rssItems, { siteTitle, baseUrl });
517
+ fs.writeFileSync(path.join(outputDir, "rss.xml"), rss, "utf8");
518
+ }
519
+ function newNoteFrontmatter(opts) {
520
+ const tags = opts.tags?.length ? `[${opts.tags.map((t) => `"${t}"`).join(", ")}]` : "[]";
521
+ let fm = `---
522
+ title: "${opts.title}"
523
+ slug: "${opts.slug}"
524
+ status: private
525
+ published_at: null
526
+ `;
527
+ if (opts.topicId) fm += `topic_id: "${opts.topicId}"
528
+ `;
529
+ if (opts.inputType) fm += `input_type: "${opts.inputType}"
530
+ `;
531
+ fm += `tone: null
532
+ structure: null
533
+ confidence: null
534
+ tags: ${tags}
535
+ ---
536
+ `;
537
+ return fm;
538
+ }
539
+ function register(api) {
540
+ const pluginDir = __dirname;
541
+ api.registerCli(({ program }) => {
542
+ const notes = program.command("notes");
543
+ notes.action(() => {
544
+ const cfg = resolveConfig(api);
545
+ const files = listNotes(cfg.notesDir);
546
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
547
+ const styleProfile = loadStyleProfile(cfg.notesDir);
548
+ const ideas = topics.filter((t) => t.status === "idea");
549
+ const drafted = topics.filter((t) => t.status === "drafted");
550
+ const refined = topics.filter((t) => t.status === "refined");
551
+ const published = topics.filter((t) => t.status === "published");
552
+ console.log(`
553
+ \u{1F4CA} Notes Dashboard`);
554
+ console.log(` ${ideas.length} ideas \xB7 ${drafted.length} drafted \xB7 ${refined.length} refined \xB7 ${published.length} published \xB7 ${files.length} files`);
555
+ if (styleProfile) {
556
+ console.log(` \u{1F3A8} Style profile: updated ${styleProfile.updated} (${styleProfile.analyzedNotes.length} notes analyzed)`);
557
+ } else {
558
+ console.log(` \u{1F3A8} No style profile yet \u2014 run: openclaw notes style`);
559
+ }
560
+ console.log();
561
+ if (published.length > 0) {
562
+ console.log(`\u{1F4E2} Published:`);
563
+ for (const t of published) {
564
+ const conf = _noteConfidence(cfg.notesDir, t.slug);
565
+ console.log(` ${t.title} \u2192 ${t.slug}${conf !== null ? ` (confidence: ${conf}/10)` : ""}`);
566
+ }
567
+ console.log();
568
+ }
569
+ if (refined.length > 0) {
570
+ console.log(`\u2728 Refined (ready to publish):`);
571
+ for (const t of refined) {
572
+ const conf = _noteConfidence(cfg.notesDir, t.slug);
573
+ console.log(` ${t.title} \u2192 ${t.slug}.md${conf !== null ? ` (confidence: ${conf}/10)` : ""}`);
574
+ }
575
+ console.log();
576
+ }
577
+ if (drafted.length > 0) {
578
+ console.log(`\u{1F4DD} Drafted:`);
579
+ for (const t of drafted) {
580
+ const varCount = _variationCount(cfg.notesDir, t.slug);
581
+ console.log(` ${t.title} \u2192 ${t.slug}.md${varCount > 0 ? ` (${varCount} variations)` : ""}`);
582
+ }
583
+ console.log();
584
+ }
585
+ const trackedSlugs = new Set(topics.map((t) => t.slug).filter(Boolean));
586
+ const orphans = files.filter((f) => !trackedSlugs.has(f.replace(/\.md$/, "")));
587
+ if (orphans.length > 0) {
588
+ console.log(`\u{1F4C4} Untracked notes:`);
589
+ for (const f of orphans) {
590
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
591
+ const { meta } = parseFrontMatter(full);
592
+ const status = meta.status === "public" ? "\u{1F4E2}" : meta.status === "refined" ? "\u2728" : "\u{1F512}";
593
+ const title = meta.title || f.replace(/\.md$/, "");
594
+ console.log(` ${status} ${title} (${f})`);
595
+ }
596
+ console.log();
597
+ }
598
+ if (ideas.length > 0) {
599
+ console.log(`\u{1F4A1} Ideas (${ideas.length}):`);
600
+ for (const t of ideas) {
601
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
602
+ console.log(` ${t.id} ${t.title}${tags}`);
603
+ }
604
+ console.log();
605
+ }
606
+ if (files.length === 0 && topics.length === 0) {
607
+ console.log(` Empty! Run: openclaw notes init`);
608
+ }
609
+ });
610
+ notes.command("init").action(() => {
611
+ const cfg = resolveConfig(api);
612
+ ensureDirs(cfg.outputDir);
613
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
614
+ const existing = listNotes(cfg.notesDir);
615
+ if (existing.length === 0) {
616
+ const templateSrc = path.join(pluginDir, "templates", "default.md");
617
+ if (fs.existsSync(templateSrc)) {
618
+ const dest = path.join(cfg.notesDir, "your-first-note.md");
619
+ fs.copyFileSync(templateSrc, dest);
620
+ console.log(` Created example note: ${dest}`);
621
+ }
622
+ }
623
+ console.log(`
624
+ \u2705 Public Thinking initialized!`);
625
+ console.log(` Notes directory: ${cfg.notesDir}`);
626
+ console.log(` Output directory: ${cfg.outputDir}`);
627
+ console.log(`
628
+ Next steps:`);
629
+ console.log(` 1. Create a note: openclaw notes new "My First Idea"`);
630
+ console.log(` 2. Edit the .md file in your notes directory`);
631
+ console.log(` 3. Publish it: openclaw notes publish my-first-idea`);
632
+ if (cfg.dailyPrompt.enabled) {
633
+ console.log(`
634
+ \u{1F4C5} Daily prompt is enabled. Run this to set up the cron job:`);
635
+ console.log(` openclaw notes setup`);
636
+ }
637
+ });
638
+ notes.command("setup").action(async () => {
639
+ const cfg = resolveConfig(api);
640
+ if (!cfg.dailyPrompt.enabled) {
641
+ console.log("Daily prompt is disabled in config. Set dailyPrompt.enabled to true to use this.");
642
+ return;
643
+ }
644
+ try {
645
+ const cronList = await api.cron?.list?.();
646
+ if (cronList && Array.isArray(cronList)) {
647
+ const existing = cronList.find((j) => j.name === "public-thinking-daily");
648
+ if (existing) {
649
+ console.log("\u2705 Cron job 'public-thinking-daily' already exists. Skipping.");
650
+ console.log(` Schedule: ${existing.schedule || existing.cron || cfg.dailyPrompt.schedule}`);
651
+ return;
652
+ }
653
+ }
654
+ } catch {
655
+ }
656
+ try {
657
+ await api.cron?.create?.({
658
+ name: "public-thinking-daily",
659
+ schedule: cfg.dailyPrompt.schedule,
660
+ timezone: cfg.dailyPrompt.timezone,
661
+ prompt: cfg.dailyPrompt.prompt
662
+ });
663
+ console.log(`\u2705 Daily prompt cron job created!`);
664
+ console.log(` Name: public-thinking-daily`);
665
+ console.log(` Schedule: ${cfg.dailyPrompt.schedule}`);
666
+ console.log(` Timezone: ${cfg.dailyPrompt.timezone}`);
667
+ } catch (err) {
668
+ console.error(`Failed to create cron job via API. You can create it manually:
669
+ `);
670
+ console.log(` openclaw cron create \\`);
671
+ console.log(` --name "public-thinking-daily" \\`);
672
+ console.log(` --schedule "${cfg.dailyPrompt.schedule}" \\`);
673
+ console.log(` --timezone "${cfg.dailyPrompt.timezone}" \\`);
674
+ console.log(` --prompt '${cfg.dailyPrompt.prompt.replace(/'/g, "'\\''")}'`);
675
+ if (err?.message) console.error(`
676
+ Error: ${err.message}`);
677
+ }
678
+ });
679
+ notes.command("new <title>").action((title) => {
680
+ const cfg = resolveConfig(api);
681
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
682
+ const base = slugify(title);
683
+ const slug = uniqueSlug(base, cfg.notesDir);
684
+ const content = newNoteFrontmatter({ title, slug }) + `
685
+ # ${title}
686
+
687
+ `;
688
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
689
+ fs.writeFileSync(filename, content, "utf8");
690
+ console.log(`Created ${filename}`);
691
+ });
692
+ notes.command("publish <slug>").option("-y, --yes", "skip confirmation").option("-r, --reason <text>", "reason for publishing").action(async (slug, options) => {
693
+ const cfg = resolveConfig(api);
694
+ const file = path.join(cfg.notesDir, `${slug}.md`);
695
+ const full = fs.readFileSync(file, "utf8");
696
+ const { meta, body } = parseFrontMatter(full);
697
+ const title = meta.title || slug;
698
+ const excerpt = excerptFrom(body);
699
+ let publishReason = options.reason || "";
700
+ if (!options.yes) {
701
+ const result = await confirmPublishPrompt(title, excerpt);
702
+ if (!result.ok) {
703
+ console.log("Publish canceled.");
704
+ return;
705
+ }
706
+ if (!publishReason) publishReason = result.reason || "";
707
+ }
708
+ const markers = extractStyleMarkers(body);
709
+ const pubMeta = {
710
+ title,
711
+ slug: meta.slug || slug,
712
+ status: "public",
713
+ published_at: (/* @__PURE__ */ new Date()).toUTCString(),
714
+ publish_reason: publishReason.replace(/"/g, '\\"'),
715
+ topic_id: meta.topic_id || null,
716
+ tone: meta.tone || null,
717
+ structure: meta.structure || null,
718
+ confidence: meta.confidence || null,
719
+ tags: meta.tags || "[]"
720
+ };
721
+ const updated = writeFrontmatterWithMarkers(pubMeta, body, markers);
722
+ fs.writeFileSync(file, updated, "utf8");
723
+ buildPublic(cfg);
724
+ const profileResult = updateAggregateProfile(cfg.notesDir);
725
+ if (profileResult.updated) {
726
+ console.log(`\u{1F3A8} Style profile auto-updated (${profileResult.noteCount} notes analyzed)`);
727
+ }
728
+ const topics = readTopics(cfg.notesDir);
729
+ const linked = topics.find((t) => t.slug === (meta.slug || slug));
730
+ if (linked && linked.status !== "published") {
731
+ linked.status = "published";
732
+ writeTopics(cfg.notesDir, topics);
733
+ }
734
+ console.log(`Published ${slug}`);
735
+ });
736
+ notes.command("unpublish <slug>").action((slug) => {
737
+ const cfg = resolveConfig(api);
738
+ const file = path.join(cfg.notesDir, `${slug}.md`);
739
+ const full = fs.readFileSync(file, "utf8");
740
+ const { meta, body } = parseFrontMatter(full);
741
+ const updated = `---
742
+ title: "${meta.title || slug}"
743
+ slug: "${meta.slug || slug}"
744
+ status: private
745
+ published_at: ${meta.published_at || "null"}
746
+ tone: ${meta.tone || "null"}
747
+ structure: ${meta.structure || "null"}
748
+ confidence: ${meta.confidence || "null"}
749
+ tags: ${meta.tags || "[]"}
750
+ ---
751
+
752
+ ${body}
753
+ `;
754
+ fs.writeFileSync(file, updated, "utf8");
755
+ const publicFile = path.join(cfg.outputDir, "posts", `${slug}.html`);
756
+ if (fs.existsSync(publicFile)) fs.unlinkSync(publicFile);
757
+ buildPublic(cfg);
758
+ console.log(`Unpublished ${slug}`);
759
+ });
760
+ notes.command("build").action(() => {
761
+ const cfg = resolveConfig(api);
762
+ buildPublic(cfg);
763
+ console.log("Built public stream + RSS");
764
+ });
765
+ notes.command("list").action(() => {
766
+ const cfg = resolveConfig(api);
767
+ const files = listNotes(cfg.notesDir);
768
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
769
+ if (files.length === 0 && topics.length === 0) {
770
+ console.log("No notes or topics found. Run `openclaw notes init` to get started.");
771
+ return;
772
+ }
773
+ if (files.length > 0) {
774
+ console.log("\n\u{1F4DD} Notes:");
775
+ for (const f of files) {
776
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
777
+ const { meta } = parseFrontMatter(full);
778
+ const status = meta.status === "public" ? "\u{1F4E2}" : meta.status === "refined" ? "\u2728" : "\u{1F512}";
779
+ const title = meta.title || f.replace(/\.md$/, "");
780
+ console.log(` ${status} ${f.padEnd(35)} ${title}`);
781
+ }
782
+ }
783
+ const ideas = topics.filter((t) => t.status === "idea");
784
+ if (ideas.length > 0) {
785
+ console.log("\n\u{1F4A1} Topics (ideas \u2014 no draft yet):");
786
+ for (const t of ideas) {
787
+ const src = t.source ? ` (${t.source})` : "";
788
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
789
+ console.log(` ${t.id} ${t.title}${src}${tags}`);
790
+ }
791
+ }
792
+ });
793
+ const topicsCmd = notes.command("topics").description("Manage topic backlog");
794
+ topicsCmd.action(() => {
795
+ const cfg = resolveConfig(api);
796
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
797
+ if (topics.length === 0) {
798
+ console.log('No topics yet. Add one with: openclaw notes topics add "Title"');
799
+ return;
800
+ }
801
+ const ideas = topics.filter((t) => t.status === "idea");
802
+ const drafted = topics.filter((t) => t.status === "drafted");
803
+ const refined = topics.filter((t) => t.status === "refined");
804
+ const published = topics.filter((t) => t.status === "published");
805
+ if (ideas.length > 0) {
806
+ console.log("\n\u{1F4A1} Ideas:");
807
+ for (const t of ideas) {
808
+ const src = t.source ? ` \u2014 ${t.source}` : "";
809
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
810
+ console.log(` ${t.id} ${t.title}${src}${tags} (${t.added})`);
811
+ }
812
+ }
813
+ if (drafted.length > 0) {
814
+ console.log("\n\u{1F4DD} Drafted:");
815
+ for (const t of drafted) console.log(` ${t.id} ${t.title} \u2192 ${t.slug}.md`);
816
+ }
817
+ if (refined.length > 0) {
818
+ console.log("\n\u2728 Refined:");
819
+ for (const t of refined) console.log(` ${t.id} ${t.title} \u2192 ${t.slug}.md`);
820
+ }
821
+ if (published.length > 0) {
822
+ console.log("\n\u{1F4E2} Published:");
823
+ for (const t of published) console.log(` ${t.id} ${t.title} \u2192 ${t.slug}.md`);
824
+ }
825
+ });
826
+ topicsCmd.command("add <title>").option("-s, --source <text>", "where the idea came from").option("-t, --tags <tags>", "comma-separated tags").action((title, options) => {
827
+ const cfg = resolveConfig(api);
828
+ const topics = readTopics(cfg.notesDir);
829
+ const topic = {
830
+ id: generateId(),
831
+ title,
832
+ source: options.source || void 0,
833
+ added: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
834
+ status: "idea",
835
+ slug: null,
836
+ tags: options.tags ? options.tags.split(",").map((s) => s.trim()) : void 0
837
+ };
838
+ topics.push(topic);
839
+ writeTopics(cfg.notesDir, topics);
840
+ console.log(`Added topic ${topic.id}: "${title}"`);
841
+ });
842
+ topicsCmd.command("draft <query>").action((query) => {
843
+ const cfg = resolveConfig(api);
844
+ const topics = readTopics(cfg.notesDir);
845
+ const topic = findTopicByIdOrTitle(topics, query);
846
+ if (!topic) {
847
+ console.log(`Topic not found: "${query}". Use 'openclaw notes topics' to see available topics.`);
848
+ return;
849
+ }
850
+ if (topic.status !== "idea") {
851
+ console.log(`Topic "${topic.title}" already has status "${topic.status}" \u2192 ${topic.slug}.md`);
852
+ return;
853
+ }
854
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
855
+ const base = slugify(topic.title);
856
+ const slug = uniqueSlug(base, cfg.notesDir);
857
+ const content = newNoteFrontmatter({ title: topic.title, slug, topicId: topic.id, tags: topic.tags }) + `
858
+ # ${topic.title}
859
+
860
+ `;
861
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
862
+ fs.writeFileSync(filename, content, "utf8");
863
+ topic.status = "drafted";
864
+ topic.slug = slug;
865
+ writeTopics(cfg.notesDir, topics);
866
+ console.log(`Created ${filename} from topic "${topic.title}"`);
867
+ });
868
+ topicsCmd.command("remove <query>").action((query) => {
869
+ const cfg = resolveConfig(api);
870
+ const topics = readTopics(cfg.notesDir);
871
+ const topic = findTopicByIdOrTitle(topics, query);
872
+ if (!topic) {
873
+ console.log(`Topic not found: "${query}".`);
874
+ return;
875
+ }
876
+ const filtered = topics.filter((t) => t.id !== topic.id);
877
+ writeTopics(cfg.notesDir, filtered);
878
+ console.log(`Removed topic ${topic.id}: "${topic.title}"`);
879
+ });
880
+ topicsCmd.command("scan").option("--suggest", "output a prompt for the agent to evaluate and propose topics").action((options) => {
881
+ const cfg = resolveConfig(api);
882
+ const workspace = resolveWorkspace(api);
883
+ const excludeDirs = /* @__PURE__ */ new Set([path.resolve(cfg.notesDir), path.resolve(cfg.outputDir)]);
884
+ const excludeDirNames = /* @__PURE__ */ new Set(["node_modules", ".git", ".cache"]);
885
+ const scanned = [];
886
+ const dirsSeen = /* @__PURE__ */ new Set();
887
+ function walkDir(dir) {
888
+ let entries;
889
+ try {
890
+ entries = fs.readdirSync(dir);
891
+ } catch {
892
+ return;
893
+ }
894
+ for (const entry of entries) {
895
+ const fullPath = path.join(dir, entry);
896
+ let stat;
897
+ try {
898
+ stat = fs.statSync(fullPath);
899
+ } catch {
900
+ continue;
901
+ }
902
+ if (stat.isDirectory()) {
903
+ if (excludeDirNames.has(entry)) continue;
904
+ if (excludeDirs.has(path.resolve(fullPath))) continue;
905
+ walkDir(fullPath);
906
+ } else if (stat.isFile() && entry.endsWith(".md")) {
907
+ const relPath = path.relative(workspace, fullPath);
908
+ dirsSeen.add(path.dirname(relPath));
909
+ let content;
910
+ try {
911
+ content = fs.readFileSync(fullPath, "utf8");
912
+ } catch {
913
+ continue;
914
+ }
915
+ let title = entry.replace(/\.md$/, "");
916
+ const headingMatch = content.match(/^#\s+(.+)$/m);
917
+ if (headingMatch) title = headingMatch[1].trim();
918
+ else {
919
+ const { meta } = parseFrontMatter(content);
920
+ if (meta.title) title = meta.title;
921
+ }
922
+ const { body } = parseFrontMatter(content);
923
+ const plainBody = body.replace(/^#+\s+.*$/gm, "").replace(/[#*_`>\[\]]/g, "").trim();
924
+ const excerpt = plainBody.slice(0, 200).replace(/\n/g, " ").trim();
925
+ scanned.push({ relPath, title, excerpt, sizeBytes: stat.size });
926
+ }
927
+ }
928
+ }
929
+ walkDir(workspace);
930
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
931
+ const noteFiles = listNotes(cfg.notesDir);
932
+ const trackedSources = /* @__PURE__ */ new Set();
933
+ for (const topic of topics) {
934
+ if (topic.source) trackedSources.add(topic.source);
935
+ if (topic.slug) {
936
+ trackedSources.add(`${topic.slug}.md`);
937
+ trackedSources.add(path.relative(workspace, path.join(cfg.notesDir, `${topic.slug}.md`)));
938
+ }
939
+ }
940
+ for (const nf of noteFiles) trackedSources.add(path.relative(workspace, path.join(cfg.notesDir, nf)));
941
+ const untracked = scanned.filter((f) => !trackedSources.has(f.relPath));
942
+ const ideas = topics.filter((t) => t.status === "idea").length;
943
+ const drafted = topics.filter((t) => t.status === "drafted").length;
944
+ const refined = topics.filter((t) => t.status === "refined").length;
945
+ const published = topics.filter((t) => t.status === "published").length;
946
+ function formatSize(bytes) {
947
+ return bytes < 1024 ? `${bytes}B` : `${(bytes / 1024).toFixed(1)}KB`;
948
+ }
949
+ if (options.suggest) {
950
+ console.log(`\u{1F916} Agent review requested. Evaluate these workspace files for publishable topics.`);
951
+ console.log(`Consider: What insights, lessons, or ideas here would be interesting as standalone blog posts?`);
952
+ console.log(`Only suggest topics that are atomic (one idea per post) and would be valuable to a general audience.
953
+ `);
954
+ console.log(`Already tracking ${topics.length} topics (${ideas} ideas, ${drafted} drafted, ${refined} refined, ${published} published).
955
+ `);
956
+ if (untracked.length === 0) {
957
+ console.log(`All ${scanned.length} workspace markdown files are already tracked. Nothing new to suggest.`);
958
+ return;
959
+ }
960
+ for (const f of untracked) {
961
+ console.log(` ${f.relPath} \u2014 "${f.title}" (${formatSize(f.sizeBytes)})`);
962
+ if (f.excerpt) console.log(` ${f.excerpt.slice(0, 120)}${f.excerpt.length > 120 ? "\u2026" : ""}`);
963
+ }
964
+ console.log(`
965
+ For each suggestion, run:
966
+ openclaw notes topics add "Title" --source "file.md" --tags "tag1,tag2"`);
967
+ } else {
968
+ console.log(`\u{1F4C2} Workspace scan complete
969
+ `);
970
+ console.log(`Found ${scanned.length} markdown files across ${dirsSeen.size} directories.`);
971
+ console.log(`Already tracking ${topics.length} topics (${ideas} ideas, ${drafted} drafted, ${refined} refined, ${published} published).
972
+ `);
973
+ if (untracked.length === 0) {
974
+ console.log(`\u2705 All workspace markdown files are already tracked or part of the notes pipeline.`);
975
+ return;
976
+ }
977
+ console.log(`\u{1F4C4} Untracked files (not linked to any topic):`);
978
+ for (const f of untracked) console.log(` ${f.relPath} \u2014 "${f.title}" (${formatSize(f.sizeBytes)})`);
979
+ console.log(`
980
+ To add a topic from this scan:
981
+ openclaw notes topics add "Title" --source "path/to/source.md"`);
982
+ }
983
+ });
984
+ notes.command("transform <slug>").option("--tone <tone>", "target tone: authentic | professional | casual | humorous").option("--structure <structure>", "target structure: structured | list | chunky | minimal | storytelling | thread | tldr").action((slug, options) => {
985
+ const cfg = resolveConfig(api);
986
+ const file = path.join(cfg.notesDir, `${slug}.md`);
987
+ if (!fs.existsSync(file)) {
988
+ console.log(`Note not found: ${slug}.md`);
989
+ return;
990
+ }
991
+ const full = fs.readFileSync(file, "utf8");
992
+ const { meta, body } = parseFrontMatter(full);
993
+ const title = meta.title || slug;
994
+ const wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
995
+ const styleProfile = loadStyleProfile(cfg.notesDir);
996
+ const tone = options.tone || meta.tone || "authentic";
997
+ const structure = options.structure || meta.structure || "structured";
998
+ const structureTpl = getStructureTemplate(pluginDir, structure);
999
+ console.log(`\u2728 Transform: "${title}"`);
1000
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1001
+ console.log(`Tone: ${tone} | Structure: ${structure}`);
1002
+ if (styleProfile) console.log(`Style profile loaded (${styleProfile.analyzedNotes.length} notes analyzed)`);
1003
+ console.log();
1004
+ console.log(`Original (${wordCount} words):`);
1005
+ console.log(body);
1006
+ console.log();
1007
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1008
+ console.log(`Transform this note into a polished blog post.
1009
+ `);
1010
+ if (styleProfile) {
1011
+ console.log(`\u{1F3A8} STYLE PROFILE \u2014 Maintain this voice:`);
1012
+ console.log(` ${styleProfile.voiceDescription}`);
1013
+ if (styleProfile.avoid?.length) console.log(` Avoid: ${styleProfile.avoid.join(", ")}`);
1014
+ if (styleProfile.examples?.strongOpeners?.length) console.log(` Example opener: "${styleProfile.examples.strongOpeners[0]}"`);
1015
+ console.log();
1016
+ }
1017
+ if (structureTpl) {
1018
+ console.log(`\u{1F4CB} STRUCTURE \u2014 ${structureTpl.name}: ${structureTpl.description}`);
1019
+ console.log(` ${structureTpl.instructions}`);
1020
+ console.log();
1021
+ }
1022
+ console.log(`\u{1F3AD} TONE \u2014 ${tone}:`);
1023
+ const toneInstructions = {
1024
+ authentic: "Preserve the author's natural voice. Mirror their tone whether formal or casual.",
1025
+ professional: "Use formal, business-appropriate language. Be clear, authoritative, and polished.",
1026
+ casual: "Use friendly, conversational language. Be approachable and relatable.",
1027
+ humorous: "Add light humor where appropriate. Be witty but not at the expense of clarity."
1028
+ };
1029
+ console.log(` ${toneInstructions[tone] || toneInstructions.authentic}`);
1030
+ console.log();
1031
+ console.log(`Content Guidelines:`);
1032
+ console.log(`- Keep it atomic: one clear idea per post`);
1033
+ console.log(`- Strong opener that hooks the reader`);
1034
+ console.log(`- Clear takeaway at the end`);
1035
+ console.log(`- Code examples should be minimal and illustrative`);
1036
+ console.log();
1037
+ console.log(`Personal Branding Guidelines:`);
1038
+ console.log(`- Position as expertise/insight, not company promotion`);
1039
+ console.log(`- Lead with the principle, not the project`);
1040
+ console.log(`- Show earned expertise: "I built this and here's what I learned"`);
1041
+ console.log(`- End with a clear, shareable takeaway`);
1042
+ console.log(`- Avoid: humble brags, buzzwords, vague claims`);
1043
+ console.log();
1044
+ console.log(`After transforming, update the note file (set tone: ${tone}, structure: ${structure} in frontmatter) and run:`);
1045
+ console.log(` openclaw notes refine ${slug}`);
1046
+ });
1047
+ notes.command("adapt <slug>").option("--platform <platform>", "target platform: linkedin | twitter | thread", "linkedin").action((slug, options) => {
1048
+ const cfg = resolveConfig(api);
1049
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1050
+ if (!fs.existsSync(file)) {
1051
+ console.log(`Note not found: ${slug}.md`);
1052
+ return;
1053
+ }
1054
+ const full = fs.readFileSync(file, "utf8");
1055
+ const { meta, body } = parseFrontMatter(full);
1056
+ const title = meta.title || slug;
1057
+ const wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
1058
+ const platform = (options.platform || "linkedin").toLowerCase();
1059
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1060
+ let namedPersona = null;
1061
+ if (meta.tone && meta.tone !== "authentic" && meta.tone !== "professional" && meta.tone !== "casual" && meta.tone !== "humorous") {
1062
+ const personaPath = path.join(cfg.notesDir, ".style-profiles", `${meta.tone}.json`);
1063
+ if (fs.existsSync(personaPath)) {
1064
+ try {
1065
+ namedPersona = JSON.parse(fs.readFileSync(personaPath, "utf8"));
1066
+ } catch {
1067
+ }
1068
+ }
1069
+ }
1070
+ const platformSpecs = {
1071
+ linkedin: {
1072
+ name: "LinkedIn",
1073
+ maxChars: 3e3,
1074
+ format: "post",
1075
+ conventions: [
1076
+ "Hook in the first 2 lines (before 'see more' fold)",
1077
+ "Short paragraphs (1-2 sentences each) with line breaks between",
1078
+ "Use line breaks generously \u2014 walls of text get scrolled past",
1079
+ "No markdown formatting \u2014 LinkedIn doesn't render it",
1080
+ "Bold-like emphasis: use CAPS sparingly or \u21B3 arrows for sub-points",
1081
+ "End with a question or call-to-action to drive comments",
1082
+ "No hashtags in the body \u2014 add 3-5 relevant ones at the very end",
1083
+ "No links in the body (kills reach) \u2014 put link in first comment instead",
1084
+ "Mention 'link in comments' if referencing external content"
1085
+ ],
1086
+ structure: "Hook (1-2 lines) \u2192 Context (2-3 short \xB6) \u2192 Core insight (2-3 short \xB6) \u2192 Takeaway or question \u2192 Hashtags",
1087
+ example: "Most equity conversations go sideways.\n\nNot because people are greedy.\nBecause they're using different frameworks.\n\nThe founder sees dilution.\nThe operator sees contribution.\nNeither is wrong \u2014 they're having two different conversations.\n\n\u21B3 Argue capital, not time\n\u21B3 Reframe dilution as value creation\n\u21B3 Bundle everything into one deal\n\u21B3 Name your number first\n\nThe frame matters more than the math.\n\nWhat's the hardest negotiation you've navigated?\n\n#equity #startups #negotiation #founders #leadership"
1088
+ },
1089
+ twitter: {
1090
+ name: "Twitter/X",
1091
+ maxChars: 280,
1092
+ format: "single tweet",
1093
+ conventions: [
1094
+ "Maximum 280 characters \u2014 every word must earn its place",
1095
+ "No hashtags unless absolutely essential (max 1-2)",
1096
+ "Strong opinion or surprising take as the hook",
1097
+ "No links (kills reach) \u2014 save for reply",
1098
+ "Use numbers and specifics for credibility",
1099
+ "End with a punchy line, not a question",
1100
+ "No emojis unless they add meaning",
1101
+ "Write like you'd text a smart friend"
1102
+ ],
1103
+ structure: "Hook/take \u2192 Supporting evidence or example \u2192 Punchline",
1104
+ example: "Most equity talks fail because the operator argues time and the founder argues dilution.\n\nArgue capital instead. Money you invested, revenue you enabled, risk you absorbed.\n\nThe person with the better spreadsheet wins."
1105
+ },
1106
+ thread: {
1107
+ name: "Twitter/X Thread",
1108
+ maxChars: 280,
1109
+ format: "numbered thread (each tweet \u2264280 chars)",
1110
+ conventions: [
1111
+ "First tweet must hook \u2014 it carries the thread",
1112
+ "Number each tweet: 1/, 2/, 3/ etc.",
1113
+ "Each tweet should be self-contained AND flow into the next",
1114
+ "5-10 tweets is the sweet spot (more = drop-off)",
1115
+ "Last tweet: recap + CTA (follow, retweet, link in reply)",
1116
+ "No tweet should feel like filler",
1117
+ "Use line breaks within tweets for readability",
1118
+ "Keep transitions minimal \u2014 'Here's the thing:', 'But:', 'The fix:'"
1119
+ ],
1120
+ structure: "1/ Hook \u2192 2-8/ One point per tweet \u2192 Final/ Recap + CTA",
1121
+ example: `1/ Most equity conversations between founders and operators go sideways.
1122
+
1123
+ Here's a framework that actually works:
1124
+
1125
+ 2/ Stop arguing time.
1126
+
1127
+ "I've been here 18 months" doesn't move a founder who's been at it for years.
1128
+
1129
+ 3/ Argue capital instead.
1130
+
1131
+ Money you invested > Revenue you enabled > Risk you absorbed > Time spent
1132
+
1133
+ Start from the top.
1134
+
1135
+ 4/ Reframe dilution as value creation.
1136
+
1137
+ "What's the company worth WITH what I built vs WITHOUT it?"
1138
+
1139
+ Don't fight their spreadsheet. Build a better one.
1140
+
1141
+ 5/ Bundle everything.
1142
+
1143
+ The moment you agree to "close the easy parts first" \u2014 you've lost your leverage.
1144
+
1145
+ 6/ The frame matters more than the math.
1146
+
1147
+ Same contribution, different perception = completely different offer.
1148
+
1149
+ Your job isn't to argue percentages. It's to control the frame.`
1150
+ }
1151
+ };
1152
+ const spec = platformSpecs[platform];
1153
+ if (!spec) {
1154
+ console.log(`Unknown platform: ${platform}`);
1155
+ console.log(`Available: ${Object.keys(platformSpecs).join(", ")}`);
1156
+ return;
1157
+ }
1158
+ const varsPath = path.join(cfg.notesDir, ".variations", slug);
1159
+ fs.mkdirSync(varsPath, { recursive: true });
1160
+ console.log(`\u{1F4F1} Adapt for ${spec.name}: "${title}"`);
1161
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1162
+ console.log(`Platform: ${spec.name} (${spec.format})`);
1163
+ console.log(`Max chars: ${spec.maxChars}`);
1164
+ console.log(`Source: ${wordCount} words
1165
+ `);
1166
+ console.log(`Original blog post:`);
1167
+ console.log(body);
1168
+ console.log();
1169
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1170
+ console.log(`Adapt this blog post into a ${spec.name} ${spec.format}.
1171
+ `);
1172
+ console.log(`\u{1F4CB} PLATFORM RULES for ${spec.name}:`);
1173
+ for (const c of spec.conventions) console.log(` \u2022 ${c}`);
1174
+ console.log();
1175
+ console.log(`\u{1F4D0} STRUCTURE: ${spec.structure}`);
1176
+ console.log();
1177
+ console.log(`\u{1F4DD} EXAMPLE of good ${spec.name} ${spec.format} on similar topic:`);
1178
+ console.log(` ${spec.example.split("\n").join("\n ")}`);
1179
+ console.log();
1180
+ if (spec.maxChars <= 280) {
1181
+ console.log(`\u26A0\uFE0F CHARACTER LIMIT: ${spec.maxChars} chars. Count carefully. Every word must earn its place.`);
1182
+ console.log();
1183
+ }
1184
+ if (namedPersona) {
1185
+ console.log(`\u{1F3AD} VOICE (${namedPersona.name}):`);
1186
+ console.log(` ${namedPersona.voiceDescription}`);
1187
+ if (namedPersona.avoid?.length) console.log(` Avoid: ${namedPersona.avoid.slice(0, 5).join(", ")}`);
1188
+ console.log();
1189
+ } else if (styleProfile?.voiceDescription) {
1190
+ console.log(`\u{1F3A8} VOICE (your style profile):`);
1191
+ console.log(` ${styleProfile.voiceDescription}`);
1192
+ console.log();
1193
+ }
1194
+ console.log(`Output Instructions:`);
1195
+ console.log(`- Distill the core insight \u2014 don't try to compress the whole post`);
1196
+ console.log(`- Match the platform's native feel \u2014 it should look like it was written for ${spec.name}, not copy-pasted from a blog`);
1197
+ console.log(`- Preserve the author's voice and perspective`);
1198
+ if (platform === "thread") {
1199
+ console.log(`- Output each tweet on its own line, prefixed with the number (1/, 2/, etc.)`);
1200
+ console.log(`- Separate tweets with a blank line`);
1201
+ }
1202
+ console.log();
1203
+ console.log(`After adapting, save the result to: ${varsPath}/${platform}.md`);
1204
+ console.log(`Format: plain text (no markdown), ready to copy-paste into ${spec.name}.`);
1205
+ });
1206
+ notes.command("analyze <slug>").action((slug) => {
1207
+ const cfg = resolveConfig(api);
1208
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1209
+ if (!fs.existsSync(file)) {
1210
+ console.log(`Note not found: ${slug}.md`);
1211
+ return;
1212
+ }
1213
+ const full = fs.readFileSync(file, "utf8");
1214
+ const { meta, body } = parseFrontMatter(full);
1215
+ const title = meta.title || slug;
1216
+ const markers = extractStyleMarkers(body);
1217
+ const updatedMeta = { ...meta, status: meta.status || "private" };
1218
+ const updated = writeFrontmatterWithMarkers(updatedMeta, body, markers);
1219
+ fs.writeFileSync(file, updated, "utf8");
1220
+ console.log(`\u{1F4CA} Style Markers: "${title}" (${slug}.md)`);
1221
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1222
+ console.log(` Sentences: ${markers.sentences}`);
1223
+ console.log(` Paragraphs: ${markers.paragraphs}`);
1224
+ console.log(` Words: ${markers.wordCount}`);
1225
+ console.log(` Avg sentence len: ${markers.avgSentenceLength} words`);
1226
+ console.log(` Avg \xB6 sentences: ${markers.avgParagraphSentences}`);
1227
+ console.log(` Perspective: ${markers.perspective}`);
1228
+ console.log(` Emoji usage: ${markers.emojiUsage}`);
1229
+ console.log(` Readability (F-K): ${markers.readabilityScore}/100`);
1230
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1231
+ console.log(`Markers saved to frontmatter.`);
1232
+ });
1233
+ notes.command("refine <slug>").action((slug) => {
1234
+ const cfg = resolveConfig(api);
1235
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1236
+ if (!fs.existsSync(file)) {
1237
+ console.log(`Note not found: ${slug}.md`);
1238
+ return;
1239
+ }
1240
+ const full = fs.readFileSync(file, "utf8");
1241
+ const { meta, body } = parseFrontMatter(full);
1242
+ const title = meta.title || slug;
1243
+ const markers = extractStyleMarkers(body);
1244
+ const refinedMeta = { ...meta, status: "refined" };
1245
+ const updated = writeFrontmatterWithMarkers(refinedMeta, body, markers);
1246
+ fs.writeFileSync(file, updated, "utf8");
1247
+ const profileResult = updateAggregateProfile(cfg.notesDir);
1248
+ if (profileResult.updated) {
1249
+ console.log(`\u{1F3A8} Style profile auto-updated (${profileResult.noteCount} notes analyzed)`);
1250
+ }
1251
+ const topics = readTopics(cfg.notesDir);
1252
+ const linked = topics.find((t) => t.slug === (meta.slug || slug));
1253
+ if (linked && linked.status !== "refined") {
1254
+ linked.status = "refined";
1255
+ writeTopics(cfg.notesDir, topics);
1256
+ }
1257
+ console.log(`\u2728 Refined: "${title}" (${slug}.md)`);
1258
+ console.log(` Status: refined \u2014 ready for review
1259
+ `);
1260
+ console.log(`Next steps:`);
1261
+ console.log(` Review: openclaw notes preview ${slug}`);
1262
+ console.log(` Test: openclaw notes test ${slug}`);
1263
+ console.log(` Publish: openclaw notes publish ${slug}`);
1264
+ });
1265
+ notes.command("test <slug>").action((slug) => {
1266
+ const cfg = resolveConfig(api);
1267
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1268
+ if (!fs.existsSync(file)) {
1269
+ console.log(`Note not found: ${slug}.md`);
1270
+ return;
1271
+ }
1272
+ const full = fs.readFileSync(file, "utf8");
1273
+ const { meta, body } = parseFrontMatter(full);
1274
+ const title = meta.title || slug;
1275
+ const wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
1276
+ const personasConfig = loadPersonas(cfg.notesDir);
1277
+ const defaultPersonas = [
1278
+ { id: "senior-dev", name: "Technical Builder", emoji: "\u{1F527}", description: "Senior developer or architect. 10+ years experience. Values depth, correctness, and novel insights. Shares content that teaches them something or validates a hard-won lesson.", evaluates: ["technical accuracy", "novelty", "depth", "shareability"] },
1279
+ { id: "founder", name: "Startup Founder", emoji: "\u{1F680}", description: "Building a product, always time-poor. Values actionable advice and hard-won lessons. Shares content that saves them time or money.", evaluates: ["actionability", "time-respect", "real-world applicability"] },
1280
+ { id: "learner", name: "Curious Learner", emoji: "\u{1F4DA}", description: "Junior developer or student. Eager but lacks context. Values clear explanations and concrete examples. Shares content that made a concept click.", evaluates: ["accessibility", "teaching quality", "concrete examples"] },
1281
+ { id: "brand-strategist", name: "Personal Brand Strategist", emoji: "\u{1F464}", description: "Evaluates whether content builds portable personal authority. Checks: Is the insight earned from real experience? Would this survive a job change? Is there a screenshot-worthy takeaway?", evaluates: ["authority building", "portability", "earned insight", "shareability"] }
1282
+ ];
1283
+ let personas = defaultPersonas;
1284
+ if (personasConfig?.sets) {
1285
+ const sets = personasConfig.defaultSets || Object.keys(personasConfig.sets);
1286
+ personas = [];
1287
+ for (const setName of sets) {
1288
+ if (personasConfig.sets[setName]?.personas) personas.push(...personasConfig.sets[setName].personas);
1289
+ }
1290
+ }
1291
+ console.log(`\u{1F9EA} Audience Test: "${title}"`);
1292
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1293
+ console.log(`
1294
+ Content (${wordCount} words):`);
1295
+ console.log(body);
1296
+ console.log(`
1297
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1298
+ console.log(`Evaluate this content from ${personas.length} perspectives:
1299
+ `);
1300
+ personas.forEach((p, i) => {
1301
+ console.log(`${i + 1}. ${p.emoji} **${p.name}**`);
1302
+ console.log(` ${p.description}`);
1303
+ console.log(` Evaluate: ${p.evaluates.join(", ")}`);
1304
+ console.log(` Rate: \u{1F44D} Would engage | \u{1F937} Maybe | \u{1F44E} Skip`);
1305
+ console.log(` Score: 1-10`);
1306
+ console.log();
1307
+ });
1308
+ console.log(`Then provide:`);
1309
+ console.log(`- Overall confidence score (0-10)`);
1310
+ console.log(`- One specific improvement suggestion
1311
+ `);
1312
+ const varDir = variationsDir(cfg.notesDir, slug);
1313
+ console.log(`\u{1F4C1} After evaluation, save results to:`);
1314
+ console.log(` ${path.join(varDir, "_test-results.json")}`);
1315
+ console.log(`
1316
+ Schema:`);
1317
+ console.log(` {`);
1318
+ console.log(` "slug": "${slug}",`);
1319
+ console.log(` "tested": "${(/* @__PURE__ */ new Date()).toISOString()}",`);
1320
+ console.log(` "scores": {`);
1321
+ personas.forEach((p) => {
1322
+ console.log(` "${p.id}": { "sentiment": "positive|negative|neutral", "engagement": "would-share|would-comment|would-read|would-skip", "score": N, "feedback": "...", "suggestion": "..." },`);
1323
+ });
1324
+ console.log(` },`);
1325
+ console.log(` "confidence": N,`);
1326
+ console.log(` "topSuggestion": "..."`);
1327
+ console.log(` }`);
1328
+ console.log(`
1329
+ Also update the note's frontmatter: confidence: N`);
1330
+ console.log(` Create directory if needed: mkdir -p ${varDir}`);
1331
+ });
1332
+ notes.command("voice <title>").action((title) => {
1333
+ const cfg = resolveConfig(api);
1334
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
1335
+ const base = slugify(title);
1336
+ const slug = uniqueSlug(base, cfg.notesDir);
1337
+ const now = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1338
+ const topics = readTopics(cfg.notesDir);
1339
+ const topic = { id: generateId(), title, source: "voice", added: now, status: "drafted", slug, tags: void 0 };
1340
+ topics.push(topic);
1341
+ writeTopics(cfg.notesDir, topics);
1342
+ const content = newNoteFrontmatter({ title, slug, topicId: topic.id, inputType: "voice" }) + "\n";
1343
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
1344
+ fs.writeFileSync(filename, content, "utf8");
1345
+ console.log(`\u{1F3A4} Voice note created`);
1346
+ console.log(` Topic: ${topic.id} \u2014 "${title}"`);
1347
+ console.log(` File: ${filename}
1348
+ `);
1349
+ console.log(`Write the transcribed content to this file, then run:`);
1350
+ console.log(` openclaw notes refine ${slug}`);
1351
+ });
1352
+ notes.command("preview <slug>").action((slug) => {
1353
+ const cfg = resolveConfig(api);
1354
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1355
+ if (!fs.existsSync(file)) {
1356
+ console.log(`Note not found: ${slug}.md`);
1357
+ return;
1358
+ }
1359
+ const full = fs.readFileSync(file, "utf8");
1360
+ const { meta, body } = parseFrontMatter(full);
1361
+ const title = meta.title || slug;
1362
+ const tags = meta.tags || "[]";
1363
+ const status = meta.status || "private";
1364
+ const excerpt = excerptFrom(body);
1365
+ const wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
1366
+ console.log(`\u{1F4D6} Preview: ${title}`);
1367
+ console.log(`${"\u2500".repeat(60)}`);
1368
+ console.log(`Status: ${status}`);
1369
+ console.log(`Slug: ${meta.slug || slug}`);
1370
+ console.log(`Tags: ${tags}`);
1371
+ console.log(`Words: ${wordCount}`);
1372
+ if (meta.tone) console.log(`Tone: ${meta.tone}`);
1373
+ if (meta.structure) console.log(`Structure: ${meta.structure}`);
1374
+ if (meta.confidence) console.log(`Confidence: ${meta.confidence}/10`);
1375
+ console.log(`Excerpt: ${excerpt}`);
1376
+ console.log(`${"\u2500".repeat(60)}`);
1377
+ console.log();
1378
+ console.log(body);
1379
+ console.log();
1380
+ console.log(`${"\u2500".repeat(60)}`);
1381
+ if (status === "public") console.log(`This note is already published.`);
1382
+ else if (status === "refined") {
1383
+ console.log(`This note is refined and ready for publishing.`);
1384
+ console.log(`To publish: openclaw notes publish ${slug} --yes --reason "your reason"`);
1385
+ } else {
1386
+ console.log(`To refine: openclaw notes transform ${slug}`);
1387
+ console.log(`To publish: openclaw notes publish ${slug} --yes --reason "your reason"`);
1388
+ }
1389
+ });
1390
+ notes.command("style").option("--from <slugs>", "comma-separated slugs to analyze (default: all refined/published)").action((options) => {
1391
+ const cfg = resolveConfig(api);
1392
+ const files = listNotes(cfg.notesDir);
1393
+ const profilePath = path.join(cfg.notesDir, ".style-profile.json");
1394
+ let notesToAnalyze = [];
1395
+ if (options.from) {
1396
+ const slugs = options.from.split(",").map((s) => s.trim());
1397
+ for (const s of slugs) {
1398
+ const f = path.join(cfg.notesDir, `${s}.md`);
1399
+ if (fs.existsSync(f)) {
1400
+ const { meta, body } = parseFrontMatter(fs.readFileSync(f, "utf8"));
1401
+ notesToAnalyze.push({ slug: s, title: meta.title || s, body });
1402
+ }
1403
+ }
1404
+ } else {
1405
+ for (const f of files) {
1406
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
1407
+ const { meta, body } = parseFrontMatter(full);
1408
+ if (meta.status === "refined" || meta.status === "public") {
1409
+ notesToAnalyze.push({ slug: f.replace(/\.md$/, ""), title: meta.title || f, body });
1410
+ }
1411
+ }
1412
+ }
1413
+ if (notesToAnalyze.length === 0) {
1414
+ console.log(`No refined or published notes found to analyze.`);
1415
+ console.log(`Refine some notes first, or use --from to specify slugs.`);
1416
+ return;
1417
+ }
1418
+ console.log(`\u{1F3A8} Style Profile Analysis`);
1419
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1420
+ console.log(`Analyzing ${notesToAnalyze.length} notes:
1421
+ `);
1422
+ for (const n of notesToAnalyze) console.log(` - ${n.title} (${n.slug})`);
1423
+ console.log(`
1424
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1425
+ console.log(`Analyze the writing style across these ${notesToAnalyze.length} notes and extract a style profile.
1426
+ `);
1427
+ console.log(`Sample content from each note:
1428
+ `);
1429
+ for (const n of notesToAnalyze) {
1430
+ const preview = n.body.slice(0, 500);
1431
+ console.log(`--- "${n.title}" ---`);
1432
+ console.log(preview);
1433
+ if (n.body.length > 500) console.log(`... (${n.body.length - 500} more chars)`);
1434
+ console.log();
1435
+ }
1436
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1437
+ console.log(`Extract and save a style profile to: ${profilePath}
1438
+ `);
1439
+ console.log(`Schema:`);
1440
+ console.log(`{`);
1441
+ console.log(` "updated": "${(/* @__PURE__ */ new Date()).toISOString()}",`);
1442
+ console.log(` "analyzedNotes": [${notesToAnalyze.map((n) => `"${n.slug}"`).join(", ")}],`);
1443
+ console.log(` "markers": {`);
1444
+ console.log(` "avgSentenceLength": "short|medium|long",`);
1445
+ console.log(` "paragraphLength": "short|medium|long",`);
1446
+ console.log(` "emojiUsage": "none|rare|frequent",`);
1447
+ console.log(` "toneDefault": "authentic|professional|casual|humorous",`);
1448
+ console.log(` "vocabulary": "description of vocabulary level",`);
1449
+ console.log(` "perspective": "first-person|third-person|mixed",`);
1450
+ console.log(` "openingStyle": "description of how posts typically open",`);
1451
+ console.log(` "closingStyle": "description of how posts typically close"`);
1452
+ console.log(` },`);
1453
+ console.log(` "voiceDescription": "2-3 sentence natural language description of the writing voice",`);
1454
+ console.log(` "avoid": ["list", "of", "anti-patterns"],`);
1455
+ console.log(` "examples": {`);
1456
+ console.log(` "strongOpeners": ["example opener sentences from the analyzed notes"],`);
1457
+ console.log(` "strongClosers": ["example closer sentences from the analyzed notes"]`);
1458
+ console.log(` }`);
1459
+ console.log(`}`);
1460
+ });
1461
+ notes.command("vary <slug>").option("--tones <tones>", "comma-separated tones (default: authentic,professional,casual,humorous)").option("--structures <structures>", "comma-separated structures (default: auto-detect best fit)").action((slug, options) => {
1462
+ const cfg = resolveConfig(api);
1463
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1464
+ if (!fs.existsSync(file)) {
1465
+ console.log(`Note not found: ${slug}.md`);
1466
+ return;
1467
+ }
1468
+ const full = fs.readFileSync(file, "utf8");
1469
+ const { meta, body } = parseFrontMatter(full);
1470
+ const title = meta.title || slug;
1471
+ const wordCount = body.split(/\s+/).filter((w) => w.length > 0).length;
1472
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1473
+ const templates = loadStructureTemplates(pluginDir);
1474
+ const tones = options.tones ? options.tones.split(",").map((s) => s.trim()) : ["authentic", "professional", "casual", "humorous"];
1475
+ const structures = options.structures ? options.structures.split(",").map((s) => s.trim()) : [meta.structure || "structured"];
1476
+ const varDir = variationsDir(cfg.notesDir, slug);
1477
+ console.log(`\u{1F500} Generate Variations: "${title}"`);
1478
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1479
+ console.log(`Tones: ${tones.join(", ")}`);
1480
+ console.log(`Structures: ${structures.join(", ")}`);
1481
+ console.log(`Variations to generate: ${tones.length * structures.length}`);
1482
+ if (styleProfile) console.log(`Style profile loaded (${styleProfile.analyzedNotes.length} notes analyzed)`);
1483
+ console.log();
1484
+ console.log(`Source (${wordCount} words):`);
1485
+ console.log(body);
1486
+ console.log();
1487
+ console.log(`\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`);
1488
+ if (styleProfile) {
1489
+ console.log(`
1490
+ \u{1F3A8} STYLE PROFILE \u2014 Maintain this voice across ALL variations:`);
1491
+ console.log(` ${styleProfile.voiceDescription}`);
1492
+ if (styleProfile.avoid?.length) console.log(` Avoid: ${styleProfile.avoid.join(", ")}`);
1493
+ console.log();
1494
+ }
1495
+ if (templates.length > 0) {
1496
+ console.log(`\u{1F4CB} STRUCTURE TEMPLATES:`);
1497
+ for (const s of structures) {
1498
+ const tpl = templates.find((t) => t.name === s);
1499
+ if (tpl) console.log(` ${tpl.name}: ${tpl.instructions}`);
1500
+ }
1501
+ console.log();
1502
+ }
1503
+ console.log(`Generate ${tones.length * structures.length} variations of this post.
1504
+ `);
1505
+ console.log(`For each variation:`);
1506
+ console.log(`1. Apply the specified tone + structure combination`);
1507
+ console.log(`2. Maintain the core insight and key points`);
1508
+ console.log(`3. Keep it atomic: one clear idea per post`);
1509
+ console.log(`4. Match the target word count for the structure`);
1510
+ console.log();
1511
+ const variationList = [];
1512
+ for (const s of structures) {
1513
+ for (const t of tones) {
1514
+ const varFile = `${t}-${s}.md`;
1515
+ variationList.push(varFile);
1516
+ console.log(`\u{1F4C4} ${varFile} \u2014 tone: ${t}, structure: ${s}`);
1517
+ }
1518
+ }
1519
+ console.log(`
1520
+ \u{1F4C1} Save all variation files to: ${varDir}/`);
1521
+ console.log(` Create directory: mkdir -p ${varDir}`);
1522
+ console.log(`
1523
+ Each variation is a standalone markdown file (no frontmatter needed).`);
1524
+ console.log(`
1525
+ Also create ${varDir}/_manifest.json:`);
1526
+ console.log(` {`);
1527
+ console.log(` "source": "${slug}.md",`);
1528
+ console.log(` "generated": "${(/* @__PURE__ */ new Date()).toISOString()}",`);
1529
+ console.log(` "variations": [`);
1530
+ for (const v of variationList) {
1531
+ const parts = v.replace(".md", "").split("-");
1532
+ const t = parts[0];
1533
+ const s = parts.slice(1).join("-");
1534
+ console.log(` { "file": "${v}", "tone": "${t}", "structure": "${s}", "wordCount": N, "tested": false, "confidence": null, "selected": false },`);
1535
+ }
1536
+ console.log(` ],`);
1537
+ console.log(` "selectedVariation": null`);
1538
+ console.log(` }`);
1539
+ });
1540
+ notes.command("variations <slug>").action((slug) => {
1541
+ const cfg = resolveConfig(api);
1542
+ const manifest = loadManifest(cfg.notesDir, slug);
1543
+ if (!manifest) {
1544
+ console.log(`No variations found for ${slug}. Run: openclaw notes vary ${slug}`);
1545
+ return;
1546
+ }
1547
+ const testResults = loadTestResults(cfg.notesDir, slug);
1548
+ console.log(`\u{1F500} Variations: "${slug}" (source: ${manifest.source})`);
1549
+ console.log(` Generated: ${manifest.generated}`);
1550
+ if (manifest.selectedVariation) console.log(` Selected: ${manifest.selectedVariation}`);
1551
+ console.log();
1552
+ for (const v of manifest.variations) {
1553
+ const selected = v.selected ? " \u2705 SELECTED" : "";
1554
+ const tested = v.tested ? ` (confidence: ${v.confidence}/10)` : "";
1555
+ const varFile = path.join(variationsDir(cfg.notesDir, slug), v.file);
1556
+ const exists = fs.existsSync(varFile);
1557
+ console.log(` ${exists ? "\u{1F4C4}" : "\u274C"} ${v.file.padEnd(30)} tone: ${v.tone.padEnd(14)} structure: ${v.structure.padEnd(12)} ${v.wordCount}w${tested}${selected}`);
1558
+ }
1559
+ if (testResults) {
1560
+ console.log(`
1561
+ Test results: confidence ${testResults.confidence}/10`);
1562
+ if (testResults.topSuggestion) console.log(` Top suggestion: ${testResults.topSuggestion}`);
1563
+ }
1564
+ console.log(`
1565
+ To pick a variation: openclaw notes pick ${slug} <tone-structure>`);
1566
+ console.log(`To test: openclaw notes test ${slug}`);
1567
+ });
1568
+ notes.command("pick <slug> <variation>").action((slug, variation) => {
1569
+ const cfg = resolveConfig(api);
1570
+ const manifest = loadManifest(cfg.notesDir, slug);
1571
+ if (!manifest) {
1572
+ console.log(`No variations found for ${slug}.`);
1573
+ return;
1574
+ }
1575
+ const varName = variation.endsWith(".md") ? variation : `${variation}.md`;
1576
+ const entry = manifest.variations.find((v) => v.file === varName || v.file === `${variation}.md`);
1577
+ if (!entry) {
1578
+ console.log(`Variation "${variation}" not found. Available:`);
1579
+ for (const v of manifest.variations) console.log(` ${v.file}`);
1580
+ return;
1581
+ }
1582
+ const varFile = path.join(variationsDir(cfg.notesDir, slug), entry.file);
1583
+ if (!fs.existsSync(varFile)) {
1584
+ console.log(`Variation file not found: ${varFile}`);
1585
+ return;
1586
+ }
1587
+ const varContent = fs.readFileSync(varFile, "utf8");
1588
+ const noteFile = path.join(cfg.notesDir, `${slug}.md`);
1589
+ const full = fs.readFileSync(noteFile, "utf8");
1590
+ const { meta } = parseFrontMatter(full);
1591
+ const title = meta.title || slug;
1592
+ const tags = meta.tags || "[]";
1593
+ const updated = `---
1594
+ title: "${title}"
1595
+ slug: "${meta.slug || slug}"
1596
+ status: ${meta.status || "private"}
1597
+ published_at: ${meta.published_at || "null"}
1598
+ ${meta.topic_id ? `topic_id: "${meta.topic_id}"
1599
+ ` : ""}tone: ${entry.tone}
1600
+ structure: ${entry.structure}
1601
+ confidence: ${meta.confidence || "null"}
1602
+ variation_of: "${entry.file}"
1603
+ tags: ${tags}
1604
+ ---
1605
+
1606
+ ${varContent}
1607
+ `;
1608
+ fs.writeFileSync(noteFile, updated, "utf8");
1609
+ for (const v of manifest.variations) v.selected = false;
1610
+ entry.selected = true;
1611
+ manifest.selectedVariation = entry.file;
1612
+ const manifestPath = path.join(variationsDir(cfg.notesDir, slug), "_manifest.json");
1613
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
1614
+ console.log(`\u2705 Picked variation: ${entry.file}`);
1615
+ console.log(` Tone: ${entry.tone} | Structure: ${entry.structure}`);
1616
+ console.log(` Content copied to ${slug}.md`);
1617
+ console.log(`
1618
+ Next: openclaw notes refine ${slug}`);
1619
+ });
1620
+ }, { commands: ["notes"] });
1621
+ const sendFile = (res, filePath, contentType) => {
1622
+ if (!fs.existsSync(filePath)) {
1623
+ res.statusCode = 404;
1624
+ res.end("Not found");
1625
+ return;
1626
+ }
1627
+ res.statusCode = 200;
1628
+ res.setHeader("Content-Type", contentType);
1629
+ fs.createReadStream(filePath).pipe(res);
1630
+ };
1631
+ api.registerHttpHandler((req, res) => {
1632
+ const cfg = resolveConfig(api);
1633
+ const url = new URL(req.url ?? "/", "http://localhost");
1634
+ const pathname = url.pathname;
1635
+ if (pathname === "/dashboard" || pathname === "/dashboard/") {
1636
+ const html = renderDashboardHtml(cfg, pluginDir);
1637
+ res.statusCode = 200;
1638
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1639
+ res.end(html);
1640
+ return true;
1641
+ }
1642
+ if (!pathname.startsWith(cfg.publicPath)) return false;
1643
+ if (pathname === cfg.publicPath || pathname === `${cfg.publicPath}/`) {
1644
+ sendFile(res, path.join(cfg.outputDir, "index.html"), "text/html; charset=utf-8");
1645
+ return true;
1646
+ }
1647
+ if (pathname === `${cfg.publicPath}/rss.xml`) {
1648
+ sendFile(res, path.join(cfg.outputDir, "rss.xml"), "application/rss+xml; charset=utf-8");
1649
+ return true;
1650
+ }
1651
+ const slug = pathname.replace(`${cfg.publicPath}/`, "");
1652
+ const file = path.join(cfg.outputDir, "posts", `${slug}.html`);
1653
+ sendFile(res, file, "text/html; charset=utf-8");
1654
+ return true;
1655
+ });
1656
+ }
1657
+ function renderDashboardHtml(cfg, pluginDir) {
1658
+ const files = listNotes(cfg.notesDir);
1659
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
1660
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1661
+ const ideas = topics.filter((t) => t.status === "idea");
1662
+ const drafted = topics.filter((t) => t.status === "drafted");
1663
+ const refined = topics.filter((t) => t.status === "refined");
1664
+ const published = topics.filter((t) => t.status === "published");
1665
+ const profilesDir = path.join(cfg.notesDir, ".style-profiles");
1666
+ const namedProfiles = [];
1667
+ if (fs.existsSync(profilesDir)) {
1668
+ for (const pf of fs.readdirSync(profilesDir).filter((f) => f.endsWith(".json"))) {
1669
+ try {
1670
+ const p = JSON.parse(fs.readFileSync(path.join(profilesDir, pf), "utf8"));
1671
+ namedProfiles.push({
1672
+ name: p.name || pf.replace(/\.json$/, ""),
1673
+ voice: p.voiceDescription || "",
1674
+ pieces: p.analyzedPieces?.length || 0,
1675
+ updated: p.updated || "\u2014"
1676
+ });
1677
+ } catch {
1678
+ }
1679
+ }
1680
+ }
1681
+ const testResults = {};
1682
+ const varsDir = path.join(cfg.notesDir, ".variations");
1683
+ if (fs.existsSync(varsDir)) {
1684
+ for (const d of fs.readdirSync(varsDir)) {
1685
+ const trFile = path.join(varsDir, d, "_test-results.json");
1686
+ if (fs.existsSync(trFile)) {
1687
+ try {
1688
+ const tr = JSON.parse(fs.readFileSync(trFile, "utf8"));
1689
+ testResults[d] = {
1690
+ confidence: tr.confidence,
1691
+ tested: tr.tested,
1692
+ topSuggestion: tr.topSuggestion || "",
1693
+ scores: tr.scores || {}
1694
+ };
1695
+ } catch {
1696
+ }
1697
+ }
1698
+ }
1699
+ }
1700
+ const noteDetails = [];
1701
+ for (const f of files) {
1702
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
1703
+ const { meta, body } = parseFrontMatter(full);
1704
+ const slug = f.replace(/\.md$/, "");
1705
+ const wc = body.split(/\s+/).filter((w) => w.length > 0).length;
1706
+ const topic = topics.find((t) => t.slug === slug);
1707
+ let sm = null;
1708
+ if (meta.style_markers) {
1709
+ try {
1710
+ sm = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers;
1711
+ } catch {
1712
+ }
1713
+ }
1714
+ noteDetails.push({
1715
+ slug,
1716
+ title: meta.title || slug,
1717
+ status: meta.status || "private",
1718
+ confidence: meta.confidence ? parseFloat(meta.confidence) : null,
1719
+ variations: _variationCount(cfg.notesDir, slug),
1720
+ wordCount: wc,
1721
+ tone: meta.tone || null,
1722
+ structure: meta.structure || null,
1723
+ topicStatus: topic?.status || "untracked",
1724
+ testResult: testResults[slug] || null,
1725
+ styleMarkers: sm
1726
+ });
1727
+ }
1728
+ const statusIcon = (s) => s === "public" ? "\u{1F4E2}" : s === "refined" ? "\u2728" : "\u{1F512}";
1729
+ const confBar = (c) => {
1730
+ if (c === null || isNaN(c)) return '<span style="color:#a1a1aa">\u2014</span>';
1731
+ const pct = Math.round(c * 10);
1732
+ const color = c >= 7 ? "#22c55e" : c >= 5 ? "#eab308" : "#ef4444";
1733
+ return `<div style="display:inline-flex;align-items:center;gap:6px"><div style="width:60px;height:8px;background:#27272a;border-radius:4px;overflow:hidden"><div style="width:${pct}%;height:100%;background:${color};border-radius:4px"></div></div><span style="font-size:0.8em;color:#a1a1aa">${c}/10</span></div>`;
1734
+ };
1735
+ const noteRows = noteDetails.sort((a, b) => {
1736
+ const order = { public: 0, refined: 1, private: 2 };
1737
+ return (order[a.status] ?? 3) - (order[b.status] ?? 3);
1738
+ }).map((n) => {
1739
+ const testScores = n.testResult ? Object.entries(n.testResult.scores).map(([k, v]) => {
1740
+ const emoji = v.score >= 7 ? "\u{1F7E2}" : v.score >= 5 ? "\u{1F7E1}" : "\u{1F534}";
1741
+ return `${emoji}${v.score}`;
1742
+ }).join(" ") : "";
1743
+ const sm = n.styleMarkers;
1744
+ const markersHtml = sm ? `<span style="font-size:0.75em;color:#a1a1aa">${sm.avgSentenceLength}w/sent \xB7 ${sm.avgParagraphSentences}s/\xB6 \xB7 ${sm.readabilityScore} FK \xB7 ${sm.perspective}</span>` : '<span style="color:#3f3f46;font-size:0.75em">\u2014</span>';
1745
+ return `<tr>
1746
+ <td>${statusIcon(n.status)}</td>
1747
+ <td><strong>${escapeHtml(n.title)}</strong><br><span style="color:#71717a;font-size:0.85em">${n.slug}</span>${n.testResult?.topSuggestion ? `<br><span style="color:#a1a1aa;font-size:0.8em;font-style:italic">\u{1F4A1} ${escapeHtml(n.testResult.topSuggestion.slice(0, 120))}${n.testResult.topSuggestion.length > 120 ? "\u2026" : ""}</span>` : ""}</td>
1748
+ <td>${n.status}</td>
1749
+ <td>${n.wordCount}w</td>
1750
+ <td>${n.tone || "\u2014"}</td>
1751
+ <td>${n.structure || "\u2014"}</td>
1752
+ <td>${markersHtml}</td>
1753
+ <td>${n.variations > 0 ? n.variations + " vars" : "\u2014"}</td>
1754
+ <td>${confBar(n.confidence)}${testScores ? `<br><span style="font-size:0.75em;color:#71717a">${testScores}</span>` : ""}</td>
1755
+ </tr>`;
1756
+ }).join("\n");
1757
+ const ideaRows = ideas.map((t) => {
1758
+ const tags = t.tags?.length ? t.tags.map((tg) => `<span style="background:#27272a;color:#a1a1aa;padding:1px 6px;border-radius:3px;font-size:0.8em">${escapeHtml(tg)}</span>`).join(" ") : "";
1759
+ return `<tr><td style="color:#71717a;font-size:0.85em">${t.id}</td><td>${escapeHtml(t.title)}</td><td>${tags}</td><td style="color:#71717a">${t.added}</td></tr>`;
1760
+ }).join("\n");
1761
+ return `<!doctype html>
1762
+ <html lang="en">
1763
+ <head>
1764
+ <meta charset="utf-8" />
1765
+ <title>Dashboard \u2014 ${cfg.siteTitle}</title>
1766
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1767
+ <style>
1768
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1769
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0a0a0a; color: #e4e4e7; max-width: 960px; margin: 0 auto; padding: 32px 24px; line-height: 1.6; }
1770
+ h1 { font-size: 1.6em; margin-bottom: 8px; color: #fafafa; }
1771
+ h2 { font-size: 1.2em; margin: 32px 0 12px; color: #a1a1aa; font-weight: 500; border-bottom: 1px solid #27272a; padding-bottom: 6px; }
1772
+ .stats { display: flex; gap: 16px; margin: 16px 0 24px; flex-wrap: wrap; }
1773
+ .stat { background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 12px 20px; min-width: 100px; }
1774
+ .stat .num { font-size: 1.8em; font-weight: 700; color: #fafafa; }
1775
+ .stat .label { font-size: 0.8em; color: #71717a; }
1776
+ .style-box { background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 16px 20px; margin: 12px 0; }
1777
+ .style-box .voice { font-style: italic; color: #d4d4d8; margin: 8px 0; }
1778
+ .style-box .avoid { color: #f87171; font-size: 0.9em; }
1779
+ table { width: 100%; border-collapse: collapse; margin: 8px 0; }
1780
+ th { text-align: left; color: #71717a; font-size: 0.8em; font-weight: 500; padding: 6px 8px; border-bottom: 1px solid #27272a; }
1781
+ td { padding: 8px; border-bottom: 1px solid #1a1a1e; vertical-align: top; font-size: 0.9em; }
1782
+ tr:hover { background: #18181b; }
1783
+ .footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #27272a; color: #52525b; font-size: 0.8em; }
1784
+ a { color: #60a5fa; text-decoration: none; }
1785
+ a:hover { text-decoration: underline; }
1786
+ </style>
1787
+ </head>
1788
+ <body>
1789
+ <h1>\u{1F4CA} ${escapeHtml(cfg.siteTitle)} \u2014 Dashboard</h1>
1790
+ <p style="color:#71717a;margin-bottom:8px">Private pipeline view \xB7 <a href="${cfg.publicPath}">Public blog \u2192</a></p>
1791
+
1792
+ <div class="stats">
1793
+ <div class="stat"><div class="num">${published.length}</div><div class="label">\u{1F4E2} Published</div></div>
1794
+ <div class="stat"><div class="num">${refined.length}</div><div class="label">\u2728 Refined</div></div>
1795
+ <div class="stat"><div class="num">${drafted.length}</div><div class="label">\u{1F4DD} Drafted</div></div>
1796
+ <div class="stat"><div class="num">${ideas.length}</div><div class="label">\u{1F4A1} Ideas</div></div>
1797
+ <div class="stat"><div class="num">${files.length}</div><div class="label">\u{1F4C4} Files</div></div>
1798
+ </div>
1799
+
1800
+ ${styleProfile ? `
1801
+ <h2>\u{1F3A8} Style Profile</h2>
1802
+ <div class="style-box">
1803
+ <div class="voice">"${escapeHtml(styleProfile.voiceDescription)}"</div>
1804
+ <div style="margin-top:8px;font-size:0.85em;color:#a1a1aa">
1805
+ Analyzed ${styleProfile.analyzedNotes.length} notes \xB7 Updated ${styleProfile.updated}
1806
+ </div>
1807
+ ${styleProfile.avoid?.length ? `<div class="avoid" style="margin-top:6px">Avoid: ${styleProfile.avoid.map((a) => escapeHtml(a)).join(", ")}</div>` : ""}
1808
+ </div>
1809
+ ` : `<h2>\u{1F3A8} Style Profile</h2><p style="color:#71717a">No style profile yet. Run <code>openclaw notes style</code> to create one.</p>`}
1810
+
1811
+ ${namedProfiles.length > 0 ? `
1812
+ <h2>\u{1F3AD} Voice Personas (${namedProfiles.length})</h2>
1813
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
1814
+ ${namedProfiles.map((p) => `
1815
+ <div class="style-box" style="flex:1;min-width:280px">
1816
+ <div style="font-weight:600;color:#fafafa;margin-bottom:4px">${escapeHtml(p.name)}</div>
1817
+ <div class="voice" style="font-size:0.85em">"${escapeHtml(p.voice.slice(0, 200))}${p.voice.length > 200 ? "\u2026" : ""}"</div>
1818
+ <div style="font-size:0.8em;color:#52525b;margin-top:6px">${p.pieces} pieces analyzed \xB7 ${p.updated}</div>
1819
+ </div>`).join("")}
1820
+ </div>
1821
+ ` : ""}
1822
+
1823
+ <h2>\u{1F4DD} Notes Pipeline</h2>
1824
+ <table>
1825
+ <thead><tr><th></th><th>Title</th><th>Status</th><th>Words</th><th>Tone</th><th>Structure</th><th>Style DNA</th><th>Vars</th><th>Confidence</th></tr></thead>
1826
+ <tbody>${noteRows}</tbody>
1827
+ </table>
1828
+
1829
+ ${_renderEvolutionHtml(styleProfile)}
1830
+
1831
+ ${ideas.length > 0 ? `
1832
+ <h2>\u{1F4A1} Topic Backlog (${ideas.length})</h2>
1833
+ <table>
1834
+ <thead><tr><th>ID</th><th>Title</th><th>Tags</th><th>Added</th></tr></thead>
1835
+ <tbody>${ideaRows}</tbody>
1836
+ </table>
1837
+ ` : ""}
1838
+
1839
+ <div class="footer">
1840
+ Generated ${(/* @__PURE__ */ new Date()).toISOString()} \xB7 <a href="${cfg.publicPath}/rss.xml">RSS</a>
1841
+ </div>
1842
+ </body>
1843
+ </html>`;
1844
+ }
1845
+ function _renderEvolutionHtml(styleProfile) {
1846
+ if (!styleProfile?.evolution?.length || styleProfile.evolution.length < 2) return "";
1847
+ const evo = styleProfile.evolution;
1848
+ const latest = evo[evo.length - 1];
1849
+ const first = evo[0];
1850
+ const slDelta = (latest.avgSentenceLength - first.avgSentenceLength).toFixed(1);
1851
+ const wcDelta = Math.round(latest.avgWordCount - first.avgWordCount);
1852
+ const rdDelta = Math.round(latest.avgReadability - first.avgReadability);
1853
+ const slArrow = Number(slDelta) > 0 ? "\u2191" : Number(slDelta) < 0 ? "\u2193" : "\u2192";
1854
+ const wcArrow = wcDelta > 0 ? "\u2191" : wcDelta < 0 ? "\u2193" : "\u2192";
1855
+ const rdArrow = rdDelta > 0 ? "\u2191" : rdDelta < 0 ? "\u2193" : "\u2192";
1856
+ const slColor = Number(slDelta) === 0 ? "#71717a" : "#60a5fa";
1857
+ const wcColor = wcDelta === 0 ? "#71717a" : "#60a5fa";
1858
+ const rdColor = rdDelta === 0 ? "#71717a" : "#60a5fa";
1859
+ const maxWc = Math.max(...evo.map((e) => e.avgWordCount || 0));
1860
+ const recentEvo = evo.slice(-10);
1861
+ const sparkBars = recentEvo.map((e) => {
1862
+ const pct = maxWc > 0 ? Math.round((e.avgWordCount || 0) / maxWc * 100) : 0;
1863
+ const h = Math.max(4, pct * 0.4);
1864
+ return '<div style="width:8px;height:' + h + 'px;background:#60a5fa;border-radius:2px" title="' + e.date + ": " + Math.round(e.avgWordCount) + 'w"></div>';
1865
+ }).join("");
1866
+ return '<h2>\u{1F4C8} Style Evolution</h2>\n<div class="style-box"><div style="display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px"><div><span style="color:#71717a;font-size:0.8em">Sentence length</span><br><span style="font-size:1.2em;font-weight:600">' + latest.avgSentenceLength + 'w</span> <span style="font-size:0.8em;color:' + slColor + '">' + slArrow + slDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Avg word count</span><br><span style="font-size:1.2em;font-weight:600">' + Math.round(latest.avgWordCount) + '</span> <span style="font-size:0.8em;color:' + wcColor + '">' + wcArrow + wcDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Readability</span><br><span style="font-size:1.2em;font-weight:600">' + latest.avgReadability + '/100</span> <span style="font-size:0.8em;color:' + rdColor + '">' + rdArrow + rdDelta + '</span></div><div><span style="color:#71717a;font-size:0.8em">Notes analyzed</span><br><span style="font-size:1.2em;font-weight:600">' + latest.noteCount + '</span></div></div><div style="display:flex;align-items:end;gap:3px;height:24px">' + sparkBars + '</div><div style="font-size:0.75em;color:#52525b;margin-top:4px">Word count trend (last ' + recentEvo.length + " snapshots) \xB7 First: " + first.date + " \xB7 Latest: " + latest.date + "</div></div>";
1867
+ }
1868
+ function _noteConfidence(notesDir, slug) {
1869
+ if (!slug) return null;
1870
+ const file = path.join(notesDir, `${slug}.md`);
1871
+ if (!fs.existsSync(file)) return null;
1872
+ const { meta } = parseFrontMatter(fs.readFileSync(file, "utf8"));
1873
+ const c = parseFloat(meta.confidence);
1874
+ return isNaN(c) ? null : c;
1875
+ }
1876
+ function _variationCount(notesDir, slug) {
1877
+ if (!slug) return 0;
1878
+ const manifest = loadManifest(notesDir, slug);
1879
+ return manifest?.variations?.length || 0;
1880
+ }
1881
+ export {
1882
+ register as default
1883
+ };