pressclaw 0.2.0 → 0.3.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/index.ts ADDED
@@ -0,0 +1,4298 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline/promises";
4
+ import crypto from "node:crypto";
5
+ import https from "node:https";
6
+
7
+ // ── HTML Templates ──────────────────────────────────────────────────
8
+
9
+ const STYLES = `
10
+ 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; }
11
+ h1 { font-size: 1.8em; margin-bottom: 0.3em; line-height: 1.2; }
12
+ h2 { font-size: 1.4em; margin-top: 1.8em; }
13
+ h3 { font-size: 1.15em; margin-top: 1.5em; }
14
+ p { margin: 1em 0; }
15
+ a { color: #0b5fff; text-decoration: none; }
16
+ a:hover { text-decoration: underline; }
17
+ strong { font-weight: 600; }
18
+ code { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 0.88em; background: #f4f4f5; padding: 2px 6px; border-radius: 4px; }
19
+ pre { background: #1e1e2e; color: #cdd6f4; padding: 20px; border-radius: 8px; overflow-x: auto; margin: 1.5em 0; line-height: 1.5; }
20
+ pre code { background: none; padding: 0; color: inherit; font-size: 0.85em; }
21
+ blockquote { border-left: 3px solid #d4d4d8; margin: 1.5em 0; padding: 0.5em 1em; color: #52525b; }
22
+ ul { padding-left: 1.5em; margin: 1em 0; }
23
+ li { margin: 0.3em 0; }
24
+ hr { border: none; border-top: 1px solid #e4e4e7; margin: 2em 0; }
25
+ .meta { color: #71717a; font-size: 0.9em; margin-bottom: 2em; }
26
+ footer { margin-top: 3em; padding-top: 1.5em; border-top: 1px solid #e4e4e7; color: #71717a; font-size: 0.85em; }
27
+ footer a { color: #71717a; }
28
+ `;
29
+
30
+ const DEFAULT_TEMPLATE = (title: string, body: string, meta: { siteTitle: string; authorName?: string; date?: string }) => `<!doctype html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <title>${title} — ${meta.siteTitle}</title>
35
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
36
+ <style>${STYLES}</style>
37
+ </head>
38
+ <body>
39
+ <h1>${title}</h1>
40
+ ${meta.date ? `<div class="meta">${meta.date}${meta.authorName ? ` · ${meta.authorName}` : ""}</div>` : ""}
41
+ <article>${body}</article>
42
+ <footer><a href="../">← ${meta.siteTitle}</a></footer>
43
+ </body>
44
+ </html>`;
45
+
46
+ const INDEX_TEMPLATE = (items: { title: string; url: string; date: string; excerpt: string }[], meta: { siteTitle: string }) => `<!doctype html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="utf-8" />
50
+ <title>${meta.siteTitle}</title>
51
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
52
+ <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>
53
+ </head>
54
+ <body>
55
+ <h1>${meta.siteTitle}</h1>
56
+ <ul class="post-list">
57
+ ${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")}
58
+ </ul>
59
+ </body>
60
+ </html>`;
61
+
62
+ const rssTemplate = (items: { title: string; url: string; date: string; excerpt: string }[], meta: { siteTitle: string; baseUrl?: string }) => `<?xml version="1.0" encoding="UTF-8"?>
63
+ <rss version="2.0">
64
+ <channel>
65
+ <title>${meta.siteTitle}</title>
66
+ <link>${meta.baseUrl ?? ""}</link>
67
+ <description>${meta.siteTitle}</description>
68
+ ${items.map((i) => `<item><title>${i.title}</title><link>${i.url}</link><pubDate>${i.date}</pubDate><description><![CDATA[${i.excerpt}]]></description></item>`).join("\n")}
69
+ </channel>
70
+ </rss>`;
71
+
72
+ // ── Helpers ──────────────────────────────────────────────────────────
73
+
74
+ function resolveWorkspace(api: any): string {
75
+ return (
76
+ api.config?.agents?.defaults?.workspace ||
77
+ process.env.HOME + "/.openclaw/workspace"
78
+ );
79
+ }
80
+
81
+ function resolveConfig(api: any) {
82
+ const cfg = api.config.plugins?.entries?.["pressclaw"]?.config ?? {};
83
+ const workspace = resolveWorkspace(api);
84
+ return {
85
+ notesDir: cfg.notesDir || path.join(workspace, "notes"),
86
+ outputDir: cfg.outputDir || path.join(workspace, "public"),
87
+ publicPath: cfg.publicPath || "/public",
88
+ siteTitle: cfg.siteTitle || "Your Thinking",
89
+ authorName: cfg.authorName || "",
90
+ baseUrl: cfg.baseUrl || "",
91
+ dailyPrompt: {
92
+ enabled: cfg.dailyPrompt?.enabled ?? true,
93
+ schedule: cfg.dailyPrompt?.schedule || "0 10 * * *",
94
+ timezone: cfg.dailyPrompt?.timezone || "UTC",
95
+ prompt:
96
+ cfg.dailyPrompt?.prompt ||
97
+ '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 — just draft. If nothing stands out, that\'s fine — skip today.',
98
+ },
99
+ };
100
+ }
101
+
102
+ function ensureDirs(outputDir: string) {
103
+ fs.mkdirSync(outputDir, { recursive: true });
104
+ fs.mkdirSync(path.join(outputDir, "posts"), { recursive: true });
105
+ }
106
+
107
+ function parseFrontMatter(content: string) {
108
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
109
+ if (!match) return { meta: {} as Record<string, any>, body: content };
110
+ const metaLines = match[1].split("\n");
111
+ const meta: Record<string, any> = {};
112
+ for (const line of metaLines) {
113
+ const [key, ...rest] = line.split(":");
114
+ if (!key) continue;
115
+ meta[key.trim()] = rest.join(":").trim().replace(/^"|"$/g, "");
116
+ }
117
+ return { meta, body: match[2].trim() };
118
+ }
119
+
120
+ function slugify(input: string) {
121
+ return input
122
+ .toLowerCase()
123
+ .replace(/[^a-z0-9\s-]/g, "")
124
+ .trim()
125
+ .replace(/\s+/g, "-")
126
+ .replace(/-+/g, "-");
127
+ }
128
+
129
+ function listNotes(notesDir: string) {
130
+ if (!fs.existsSync(notesDir)) return [];
131
+ return fs.readdirSync(notesDir).filter((f) => f.endsWith(".md"));
132
+ }
133
+
134
+ // ── Style DNA: Per-note analysis + aggregate profile evolution ───────
135
+
136
+ function estimateSyllables(word: string): number {
137
+ word = word.toLowerCase().replace(/[^a-z]/g, "");
138
+ if (word.length <= 3) return 1;
139
+ word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "");
140
+ word = word.replace(/^y/, "");
141
+ const vowelGroups = word.match(/[aeiouy]{1,2}/g);
142
+ return vowelGroups ? vowelGroups.length : 1;
143
+ }
144
+
145
+ function extractStyleMarkers(body: string): Record<string, any> {
146
+ // Strip markdown formatting for analysis
147
+ const text = body
148
+ .replace(/^#+\s+.*/gm, "") // headers
149
+ .replace(/\*\*([^*]+)\*\*/g, "$1") // bold
150
+ .replace(/\*([^*]+)\*/g, "$1") // italic
151
+ .replace(/`[^`]+`/g, "CODE") // inline code
152
+ .replace(/```[\s\S]*?```/g, "") // code blocks
153
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links
154
+ .replace(/^>\s+/gm, "") // blockquotes
155
+ .replace(/^[-*]\s+/gm, "") // list items
156
+ .replace(/^\d+\.\s+/gm, "") // numbered lists
157
+ .trim();
158
+
159
+ const sentences = text.split(/[.!?]+/).map((s) => s.trim()).filter((s) => s.length > 3);
160
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
161
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
162
+
163
+ const avgSentenceLength = sentences.length > 0
164
+ ? Math.round((words.length / sentences.length) * 10) / 10
165
+ : 0;
166
+ const avgParagraphSentences = paragraphs.length > 0
167
+ ? Math.round((sentences.length / paragraphs.length) * 10) / 10
168
+ : 0;
169
+
170
+ // Perspective detection
171
+ const firstPerson = (text.match(/\b(I|I've|I'm|my|me|we|we've|our)\b/gi) || []).length;
172
+ const secondPerson = (text.match(/\b(you|your|you're|you've)\b/gi) || []).length;
173
+ const perspective = firstPerson > secondPerson * 2 ? "first-person"
174
+ : secondPerson > firstPerson * 2 ? "second-person"
175
+ : "mixed";
176
+
177
+ // Emoji detection
178
+ const emojiCount = (text.match(/[\u{1F300}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F900}-\u{1F9FF}]/gu) || []).length;
179
+ const emojiUsage = emojiCount === 0 ? "none" : emojiCount < 3 ? "rare" : "frequent";
180
+
181
+ // Flesch-Kincaid readability approximation
182
+ const syllableCount = words.reduce((sum, w) => sum + estimateSyllables(w), 0);
183
+ const readability = Math.round(
184
+ 206.835 - 1.015 * (words.length / Math.max(sentences.length, 1)) -
185
+ 84.6 * (syllableCount / Math.max(words.length, 1))
186
+ );
187
+
188
+ return {
189
+ sentences: sentences.length,
190
+ paragraphs: paragraphs.length,
191
+ wordCount: words.length,
192
+ avgSentenceLength,
193
+ avgParagraphSentences,
194
+ perspective,
195
+ emojiUsage,
196
+ readabilityScore: Math.max(0, Math.min(100, readability)),
197
+ };
198
+ }
199
+
200
+ function writeFrontmatterWithMarkers(
201
+ meta: Record<string, any>,
202
+ body: string,
203
+ markers: Record<string, any>
204
+ ): string {
205
+ const lines: string[] = ["---"];
206
+ // Preserve key order: title, slug, status, published_at, then the rest
207
+ const ordered = ["title", "slug", "status", "published_at", "publish_reason", "topic_id", "input_type", "tone", "structure", "confidence"];
208
+ const written = new Set<string>();
209
+
210
+ for (const key of ordered) {
211
+ if (meta[key] !== undefined && meta[key] !== null) {
212
+ const val = meta[key];
213
+ if (key === "title" || key === "slug" || key === "publish_reason" || key === "topic_id" || key === "input_type") {
214
+ lines.push(`${key}: "${val}"`);
215
+ } else {
216
+ lines.push(`${key}: ${val}`);
217
+ }
218
+ written.add(key);
219
+ }
220
+ }
221
+
222
+ // Tags (array)
223
+ if (meta.tags) {
224
+ lines.push(`tags: ${typeof meta.tags === "string" ? meta.tags : JSON.stringify(meta.tags)}`);
225
+ written.add("tags");
226
+ }
227
+
228
+ // Style markers as single-line JSON
229
+ lines.push(`style_markers: ${JSON.stringify(markers)}`);
230
+ written.add("style_markers");
231
+
232
+ // Any remaining meta fields
233
+ for (const [key, val] of Object.entries(meta)) {
234
+ if (written.has(key)) continue;
235
+ if (key === "style_markers") continue; // skip old one
236
+ lines.push(`${key}: ${val}`);
237
+ }
238
+
239
+ lines.push("---");
240
+ return lines.join("\n") + "\n\n" + body + "\n";
241
+ }
242
+
243
+ function updateAggregateProfile(notesDir: string): { noteCount: number; updated: boolean } {
244
+ const files = listNotes(notesDir);
245
+ const now = new Date();
246
+ const noteData: {
247
+ slug: string;
248
+ markers: Record<string, any>;
249
+ publishedAt: string | null;
250
+ weight: number;
251
+ }[] = [];
252
+
253
+ for (const f of files) {
254
+ const full = fs.readFileSync(path.join(notesDir, f), "utf8");
255
+ const { meta, body } = parseFrontMatter(full);
256
+ if (meta.status !== "public" && meta.status !== "refined") continue;
257
+
258
+ const slug = f.replace(/\.md$/, "");
259
+
260
+ // Use cached markers from frontmatter, or compute fresh
261
+ let markers: Record<string, any>;
262
+ if (meta.style_markers) {
263
+ try {
264
+ markers = typeof meta.style_markers === "string"
265
+ ? JSON.parse(meta.style_markers)
266
+ : meta.style_markers;
267
+ } catch {
268
+ markers = extractStyleMarkers(body);
269
+ }
270
+ } else {
271
+ markers = extractStyleMarkers(body);
272
+ }
273
+
274
+ // Weight by recency (slow decay)
275
+ const pubDate = meta.published_at ? new Date(meta.published_at) : now;
276
+ const daysSince = Math.max(0, (now.getTime() - pubDate.getTime()) / (1000 * 60 * 60 * 24));
277
+ const weight = 1 / (1 + daysSince * 0.01);
278
+
279
+ noteData.push({
280
+ slug,
281
+ markers,
282
+ publishedAt: meta.published_at || null,
283
+ weight,
284
+ });
285
+ }
286
+
287
+ if (noteData.length === 0) return { noteCount: 0, updated: false };
288
+
289
+ // Weighted averages for numeric fields
290
+ const totalWeight = noteData.reduce((s, n) => s + n.weight, 0);
291
+ const wavg = (field: string) => {
292
+ const sum = noteData.reduce((s, n) => s + (n.markers[field] || 0) * n.weight, 0);
293
+ return Math.round((sum / totalWeight) * 10) / 10;
294
+ };
295
+
296
+ const aggAvgSentenceLength = wavg("avgSentenceLength");
297
+ const aggAvgWordCount = wavg("wordCount");
298
+ const aggAvgReadability = wavg("readabilityScore");
299
+ const aggAvgParagraphSentences = wavg("avgParagraphSentences");
300
+
301
+ // Perspective: majority vote weighted
302
+ const perspectiveCounts: Record<string, number> = {};
303
+ for (const n of noteData) {
304
+ const p = n.markers.perspective || "mixed";
305
+ perspectiveCounts[p] = (perspectiveCounts[p] || 0) + n.weight;
306
+ }
307
+ const aggPerspective = Object.entries(perspectiveCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "mixed";
308
+
309
+ // Emoji: majority vote weighted
310
+ const emojiCounts: Record<string, number> = {};
311
+ for (const n of noteData) {
312
+ const e = n.markers.emojiUsage || "none";
313
+ emojiCounts[e] = (emojiCounts[e] || 0) + n.weight;
314
+ }
315
+ const aggEmoji = Object.entries(emojiCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "none";
316
+
317
+ // Sentence length category
318
+ const slCat = aggAvgSentenceLength < 12 ? "short" : aggAvgSentenceLength > 20 ? "long" : "medium";
319
+ const plCat = aggAvgParagraphSentences < 3 ? "short" : aggAvgParagraphSentences > 5 ? "long" : "medium";
320
+
321
+ // Build per-note markers map
322
+ const perNoteMarkers: Record<string, any> = {};
323
+ for (const n of noteData) {
324
+ perNoteMarkers[n.slug] = {
325
+ ...n.markers,
326
+ weight: Math.round(n.weight * 1000) / 1000,
327
+ publishedAt: n.publishedAt,
328
+ };
329
+ }
330
+
331
+ // Load existing profile to preserve qualitative fields
332
+ const profilePath = path.join(notesDir, ".style-profile.json");
333
+ let existing: any = {};
334
+ if (fs.existsSync(profilePath)) {
335
+ try { existing = JSON.parse(fs.readFileSync(profilePath, "utf8")); } catch {}
336
+ }
337
+
338
+ // Evolution tracking — append snapshot if date changed
339
+ const evolution: any[] = existing.evolution || [];
340
+ const todayStr = now.toISOString().slice(0, 10);
341
+ const lastEvolution = evolution[evolution.length - 1];
342
+ if (!lastEvolution || lastEvolution.date !== todayStr) {
343
+ evolution.push({
344
+ date: todayStr,
345
+ noteCount: noteData.length,
346
+ avgSentenceLength: aggAvgSentenceLength,
347
+ avgWordCount: aggAvgWordCount,
348
+ avgReadability: aggAvgReadability,
349
+ });
350
+ // Keep last 50
351
+ while (evolution.length > 50) evolution.shift();
352
+ } else {
353
+ // Update today's entry
354
+ lastEvolution.noteCount = noteData.length;
355
+ lastEvolution.avgSentenceLength = aggAvgSentenceLength;
356
+ lastEvolution.avgWordCount = aggAvgWordCount;
357
+ lastEvolution.avgReadability = aggAvgReadability;
358
+ }
359
+
360
+ const updated: any = {
361
+ updated: now.toISOString(),
362
+ analyzedNotes: noteData.map((n) => n.slug),
363
+ noteCount: noteData.length,
364
+ markers: {
365
+ avgSentenceLength: slCat,
366
+ paragraphLength: plCat,
367
+ emojiUsage: aggEmoji,
368
+ perspective: aggPerspective,
369
+ // Preserve existing qualitative markers
370
+ ...(existing.markers?.vocabulary ? { vocabulary: existing.markers.vocabulary } : {}),
371
+ ...(existing.markers?.toneDefault ? { toneDefault: existing.markers.toneDefault } : {}),
372
+ ...(existing.markers?.openingStyle ? { openingStyle: existing.markers.openingStyle } : {}),
373
+ ...(existing.markers?.closingStyle ? { closingStyle: existing.markers.closingStyle } : {}),
374
+ },
375
+ perNoteMarkers,
376
+ evolution,
377
+ // Preserve qualitative fields from existing profile
378
+ voiceDescription: existing.voiceDescription || "",
379
+ avoid: existing.avoid || [],
380
+ examples: existing.examples || {},
381
+ };
382
+
383
+ fs.writeFileSync(profilePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
384
+ return { noteCount: noteData.length, updated: true };
385
+ }
386
+
387
+ // ── Branding OS Helpers ─────────────────────────────────────────────
388
+
389
+ interface StructureTemplate {
390
+ name: string;
391
+ description: string;
392
+ instructions: string;
393
+ }
394
+
395
+ interface StyleProfile {
396
+ updated: string;
397
+ analyzedNotes: string[];
398
+ markers: Record<string, string>;
399
+ voiceDescription: string;
400
+ avoid: string[];
401
+ examples: { strongOpeners: string[]; strongClosers: string[] };
402
+ }
403
+
404
+ interface VariationEntry {
405
+ file: string;
406
+ tone: string;
407
+ structure: string;
408
+ wordCount: number;
409
+ tested: boolean;
410
+ confidence: number | null;
411
+ selected: boolean;
412
+ }
413
+
414
+ interface VariationManifest {
415
+ source: string;
416
+ generated: string;
417
+ variations: VariationEntry[];
418
+ selectedVariation: string | null;
419
+ }
420
+
421
+ function loadStructureTemplates(pluginDir: string): StructureTemplate[] {
422
+ const p = path.join(pluginDir, "templates", "structures.json");
423
+ if (!fs.existsSync(p)) return [];
424
+ try {
425
+ return JSON.parse(fs.readFileSync(p, "utf8"));
426
+ } catch {
427
+ return [];
428
+ }
429
+ }
430
+
431
+ function getStructureTemplate(pluginDir: string, name: string): StructureTemplate | undefined {
432
+ return loadStructureTemplates(pluginDir).find((t) => t.name === name);
433
+ }
434
+
435
+ function loadStyleProfile(notesDir: string): StyleProfile | null {
436
+ const p = path.join(notesDir, ".style-profile.json");
437
+ if (!fs.existsSync(p)) return null;
438
+ try {
439
+ return JSON.parse(fs.readFileSync(p, "utf8"));
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ function variationsDir(notesDir: string, slug: string): string {
446
+ return path.join(notesDir, ".variations", slug);
447
+ }
448
+
449
+ function loadManifest(notesDir: string, slug: string): VariationManifest | null {
450
+ const p = path.join(variationsDir(notesDir, slug), "_manifest.json");
451
+ if (!fs.existsSync(p)) return null;
452
+ try {
453
+ return JSON.parse(fs.readFileSync(p, "utf8"));
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ function loadTestResults(notesDir: string, slug: string): any | null {
460
+ const p = path.join(variationsDir(notesDir, slug), "_test-results.json");
461
+ if (!fs.existsSync(p)) return null;
462
+ try {
463
+ return JSON.parse(fs.readFileSync(p, "utf8"));
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ function loadPersonas(notesDir: string): any | null {
470
+ const p = path.join(notesDir, ".personas.json");
471
+ if (!fs.existsSync(p)) return null;
472
+ try {
473
+ return JSON.parse(fs.readFileSync(p, "utf8"));
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+
479
+ // ── Performance Feedback Helpers ────────────────────────────────────
480
+
481
+ interface FeedbackEntry {
482
+ date: string;
483
+ score: number;
484
+ metrics: { views?: number; likes?: number; shares?: number; comments?: number };
485
+ platform?: string;
486
+ note?: string;
487
+ }
488
+
489
+ interface TweetRecord {
490
+ id: string;
491
+ text: string;
492
+ postedAt: string;
493
+ platform: "twitter" | "linkedin";
494
+ }
495
+
496
+ interface FeedbackFile {
497
+ slug: string;
498
+ entries: FeedbackEntry[];
499
+ tweets?: TweetRecord[];
500
+ aggregate: {
501
+ avgScore: number;
502
+ totalViews: number;
503
+ totalLikes: number;
504
+ totalShares: number;
505
+ totalComments: number;
506
+ entries: number;
507
+ };
508
+ }
509
+
510
+ function feedbackDir(notesDir: string): string {
511
+ return path.join(notesDir, ".feedback");
512
+ }
513
+
514
+ function feedbackPath(notesDir: string, slug: string): string {
515
+ return path.join(feedbackDir(notesDir), `${slug}.json`);
516
+ }
517
+
518
+ function loadFeedback(notesDir: string, slug: string): FeedbackFile | null {
519
+ const p = feedbackPath(notesDir, slug);
520
+ if (!fs.existsSync(p)) return null;
521
+ try {
522
+ return JSON.parse(fs.readFileSync(p, "utf8"));
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+
528
+ function loadAllFeedback(notesDir: string): FeedbackFile[] {
529
+ const dir = feedbackDir(notesDir);
530
+ if (!fs.existsSync(dir)) return [];
531
+ const results: FeedbackFile[] = [];
532
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".json"))) {
533
+ try {
534
+ results.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")));
535
+ } catch {}
536
+ }
537
+ return results;
538
+ }
539
+
540
+ function recalcAggregate(entries: FeedbackEntry[]): FeedbackFile["aggregate"] {
541
+ const n = entries.length;
542
+ if (n === 0) return { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 };
543
+ const totalViews = entries.reduce((s, e) => s + (e.metrics.views || 0), 0);
544
+ const totalLikes = entries.reduce((s, e) => s + (e.metrics.likes || 0), 0);
545
+ const totalShares = entries.reduce((s, e) => s + (e.metrics.shares || 0), 0);
546
+ const totalComments = entries.reduce((s, e) => s + (e.metrics.comments || 0), 0);
547
+ const avgScore = Math.round((entries.reduce((s, e) => s + e.score, 0) / n) * 10) / 10;
548
+ return { avgScore, totalViews, totalLikes, totalShares, totalComments, entries: n };
549
+ }
550
+
551
+ function saveFeedback(notesDir: string, data: FeedbackFile): void {
552
+ const dir = feedbackDir(notesDir);
553
+ fs.mkdirSync(dir, { recursive: true });
554
+ fs.writeFileSync(feedbackPath(notesDir, data.slug), JSON.stringify(data, null, 2) + "\n", "utf8");
555
+ }
556
+
557
+ // ── Twitter/X Posting Helpers ──────────────────────────────────────
558
+
559
+ function twitterOAuthHeader(method: string, url: string, consumerKey: string, consumerSecret: string, token: string, tokenSecret: string): string {
560
+ const enc = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
561
+ const params: Record<string, string> = {
562
+ oauth_consumer_key: consumerKey,
563
+ oauth_nonce: crypto.randomUUID().replace(/-/g, ""),
564
+ oauth_signature_method: "HMAC-SHA1",
565
+ oauth_timestamp: String(Math.floor(Date.now() / 1000)),
566
+ oauth_token: token,
567
+ oauth_version: "1.0",
568
+ };
569
+ const sorted = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join("&");
570
+ const baseString = `${method}&${enc(url)}&${enc(sorted)}`;
571
+ const signingKey = `${enc(consumerSecret)}&${enc(tokenSecret)}`;
572
+ const signature = crypto.createHmac("sha1", signingKey).update(baseString).digest("base64");
573
+ params.oauth_signature = signature;
574
+ return "OAuth " + Object.keys(params).sort().map((k) => `${enc(k)}="${enc(params[k])}"`).join(", ");
575
+ }
576
+
577
+ function postTweet(text: string): Promise<{ id: string; text: string }> {
578
+ const apiKey = process.env.TWITTER_API_KEY;
579
+ const apiSecret = process.env.TWITTER_API_SECRET;
580
+ const accessToken = process.env.TWITTER_ACCESS_TOKEN;
581
+ const accessSecret = process.env.TWITTER_ACCESS_SECRET;
582
+ if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
583
+ return Promise.reject(new Error("Twitter credentials not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET environment variables."));
584
+ }
585
+ const url = "https://api.twitter.com/2/tweets";
586
+ const auth = twitterOAuthHeader("POST", url, apiKey, apiSecret, accessToken, accessSecret);
587
+ const body = JSON.stringify({ text });
588
+ return new Promise((resolve, reject) => {
589
+ const req = https.request(url, { method: "POST", headers: { Authorization: auth, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } }, (res) => {
590
+ let data = "";
591
+ res.on("data", (chunk) => (data += chunk));
592
+ res.on("end", () => {
593
+ try {
594
+ const parsed = JSON.parse(data);
595
+ if (res.statusCode === 201 && parsed.data) {
596
+ resolve({ id: parsed.data.id, text: parsed.data.text });
597
+ } else {
598
+ reject(new Error(`Twitter API error (${res.statusCode}): ${data}`));
599
+ }
600
+ } catch {
601
+ reject(new Error(`Twitter API parse error: ${data}`));
602
+ }
603
+ });
604
+ });
605
+ req.on("error", reject);
606
+ req.write(body);
607
+ req.end();
608
+ });
609
+ }
610
+
611
+ function fetchTweetMetrics(tweetIds: string[]): Promise<{ id: string; metrics: { views: number; likes: number; shares: number; comments: number } }[]> {
612
+ const apiKey = process.env.TWITTER_API_KEY;
613
+ const apiSecret = process.env.TWITTER_API_SECRET;
614
+ const accessToken = process.env.TWITTER_ACCESS_TOKEN;
615
+ const accessSecret = process.env.TWITTER_ACCESS_SECRET;
616
+ if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
617
+ return Promise.reject(new Error("Twitter credentials not configured. Set TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET environment variables."));
618
+ }
619
+ const batches: string[][] = [];
620
+ for (let i = 0; i < tweetIds.length; i += 100) {
621
+ batches.push(tweetIds.slice(i, i + 100));
622
+ }
623
+ return Promise.all(batches.map((batch) => {
624
+ const url = `https://api.twitter.com/2/tweets?ids=${batch.join(",")}&tweet.fields=public_metrics`;
625
+ const auth = twitterOAuthHeader("GET", url.split("?")[0], apiKey, apiSecret, accessToken, accessSecret);
626
+ return new Promise<{ id: string; metrics: { views: number; likes: number; shares: number; comments: number } }[]>((resolve, reject) => {
627
+ const req = https.request(url, { method: "GET", headers: { Authorization: auth } }, (res) => {
628
+ let data = "";
629
+ res.on("data", (chunk) => (data += chunk));
630
+ res.on("end", () => {
631
+ try {
632
+ const parsed = JSON.parse(data);
633
+ if (res.statusCode === 200 && parsed.data) {
634
+ resolve(parsed.data.map((t: any) => ({
635
+ id: t.id,
636
+ metrics: {
637
+ views: t.public_metrics?.impression_count || 0,
638
+ likes: t.public_metrics?.like_count || 0,
639
+ shares: (t.public_metrics?.retweet_count || 0) + (t.public_metrics?.quote_count || 0),
640
+ comments: t.public_metrics?.reply_count || 0,
641
+ },
642
+ })));
643
+ } else {
644
+ reject(new Error(`Twitter API error (${res.statusCode}): ${data}`));
645
+ }
646
+ } catch {
647
+ reject(new Error(`Twitter API parse error: ${data}`));
648
+ }
649
+ });
650
+ });
651
+ req.on("error", reject);
652
+ req.end();
653
+ });
654
+ })).then((results) => results.flat());
655
+ }
656
+
657
+ // ── Scheduled Posting Helpers ──────────────────────────────────────
658
+
659
+ interface ScheduleEntry {
660
+ slug: string;
661
+ platform: "twitter" | "linkedin";
662
+ scheduledAt: string; // ISO datetime
663
+ text: string;
664
+ status: "queued" | "posted" | "failed" | "cancelled";
665
+ tweetId?: string;
666
+ error?: string;
667
+ createdAt: string;
668
+ }
669
+
670
+ function scheduleDir(notesDir: string): string {
671
+ return path.join(notesDir, ".schedule");
672
+ }
673
+
674
+ function schedulePath(notesDir: string): string {
675
+ return path.join(scheduleDir(notesDir), "queue.json");
676
+ }
677
+
678
+ function loadQueue(notesDir: string): ScheduleEntry[] {
679
+ const p = schedulePath(notesDir);
680
+ if (!fs.existsSync(p)) return [];
681
+ try {
682
+ return JSON.parse(fs.readFileSync(p, "utf8"));
683
+ } catch {
684
+ return [];
685
+ }
686
+ }
687
+
688
+ function saveQueue(notesDir: string, queue: ScheduleEntry[]): void {
689
+ const dir = scheduleDir(notesDir);
690
+ fs.mkdirSync(dir, { recursive: true });
691
+ fs.writeFileSync(schedulePath(notesDir), JSON.stringify(queue, null, 2) + "\n", "utf8");
692
+ }
693
+
694
+ // ── LinkedIn Posting Helpers ──────────────────────────────────────
695
+
696
+ function postLinkedIn(text: string): Promise<{ id: string }> {
697
+ const accessToken = process.env.LINKEDIN_ACCESS_TOKEN;
698
+ const personUrn = process.env.LINKEDIN_PERSON_URN;
699
+ if (!accessToken || !personUrn) {
700
+ return Promise.reject(
701
+ new Error(
702
+ "LinkedIn credentials not configured. Set LINKEDIN_ACCESS_TOKEN and LINKEDIN_PERSON_URN environment variables.\n" +
703
+ "Run: openclaw notes linkedin-auth"
704
+ )
705
+ );
706
+ }
707
+
708
+ const payload = JSON.stringify({
709
+ author: personUrn,
710
+ commentary: text,
711
+ visibility: "PUBLIC",
712
+ distribution: {
713
+ feedDistribution: "MAIN_FEED",
714
+ targetEntities: [],
715
+ thirdPartyDistributionChannels: [],
716
+ },
717
+ lifecycleState: "PUBLISHED",
718
+ isReshareDisabledByAuthor: false,
719
+ });
720
+
721
+ return new Promise((resolve, reject) => {
722
+ const req = https.request(
723
+ "https://api.linkedin.com/rest/posts",
724
+ {
725
+ method: "POST",
726
+ headers: {
727
+ Authorization: `Bearer ${accessToken}`,
728
+ "Content-Type": "application/json",
729
+ "Content-Length": Buffer.byteLength(payload),
730
+ "LinkedIn-Version": "202401",
731
+ "X-Restli-Protocol-Version": "2.0.0",
732
+ },
733
+ },
734
+ (res) => {
735
+ let data = "";
736
+ res.on("data", (chunk) => (data += chunk));
737
+ res.on("end", () => {
738
+ if (res.statusCode === 201) {
739
+ const postId = res.headers["x-restli-id"] as string || "";
740
+ resolve({ id: postId });
741
+ } else {
742
+ reject(new Error(`LinkedIn API error (${res.statusCode}): ${data}`));
743
+ }
744
+ });
745
+ }
746
+ );
747
+ req.on("error", reject);
748
+ req.write(payload);
749
+ req.end();
750
+ });
751
+ }
752
+
753
+ function exchangeLinkedInAuthCode(
754
+ clientId: string,
755
+ clientSecret: string,
756
+ code: string,
757
+ redirectUri: string
758
+ ): Promise<{ access_token: string; expires_in: number; scope: string }> {
759
+ const body = [
760
+ `grant_type=authorization_code`,
761
+ `code=${encodeURIComponent(code)}`,
762
+ `client_id=${encodeURIComponent(clientId)}`,
763
+ `client_secret=${encodeURIComponent(clientSecret)}`,
764
+ `redirect_uri=${encodeURIComponent(redirectUri)}`,
765
+ ].join("&");
766
+
767
+ return new Promise((resolve, reject) => {
768
+ const req = https.request(
769
+ "https://www.linkedin.com/oauth/v2/accessToken",
770
+ {
771
+ method: "POST",
772
+ headers: {
773
+ "Content-Type": "application/x-www-form-urlencoded",
774
+ "Content-Length": Buffer.byteLength(body),
775
+ },
776
+ },
777
+ (res) => {
778
+ let data = "";
779
+ res.on("data", (chunk) => (data += chunk));
780
+ res.on("end", () => {
781
+ try {
782
+ const parsed = JSON.parse(data);
783
+ if (res.statusCode === 200 && parsed.access_token) {
784
+ resolve(parsed);
785
+ } else {
786
+ reject(
787
+ new Error(
788
+ `LinkedIn OAuth error (${res.statusCode}): ${parsed.error_description || parsed.error || data}`
789
+ )
790
+ );
791
+ }
792
+ } catch {
793
+ reject(new Error(`LinkedIn OAuth parse error: ${data}`));
794
+ }
795
+ });
796
+ }
797
+ );
798
+ req.on("error", reject);
799
+ req.write(body);
800
+ req.end();
801
+ });
802
+ }
803
+
804
+ function fetchLinkedInPersonUrn(accessToken: string): Promise<string> {
805
+ return new Promise((resolve, reject) => {
806
+ const req = https.request(
807
+ "https://api.linkedin.com/v2/me",
808
+ {
809
+ method: "GET",
810
+ headers: {
811
+ Authorization: `Bearer ${accessToken}`,
812
+ "X-Restli-Protocol-Version": "2.0.0",
813
+ },
814
+ },
815
+ (res) => {
816
+ let data = "";
817
+ res.on("data", (chunk) => (data += chunk));
818
+ res.on("end", () => {
819
+ try {
820
+ const parsed = JSON.parse(data);
821
+ if (res.statusCode === 200 && parsed.id) {
822
+ resolve(`urn:li:person:${parsed.id}`);
823
+ } else {
824
+ reject(new Error(`LinkedIn profile API error (${res.statusCode}): ${data}`));
825
+ }
826
+ } catch {
827
+ reject(new Error(`LinkedIn profile API parse error: ${data}`));
828
+ }
829
+ });
830
+ }
831
+ );
832
+ req.on("error", reject);
833
+ req.end();
834
+ });
835
+ }
836
+
837
+ // ── Persona Marketplace Helpers ───────────────────────────────────
838
+
839
+ interface Persona {
840
+ name: string;
841
+ displayName: string;
842
+ description: string;
843
+ version: string;
844
+ author: string;
845
+ style: {
846
+ tone: string;
847
+ avgSentenceLength?: number;
848
+ vocabulary?: string;
849
+ perspective?: string;
850
+ patterns: string[];
851
+ };
852
+ examples: string[];
853
+ tags: string[];
854
+ }
855
+
856
+ function personasDir(notesDir: string): string {
857
+ return path.join(notesDir, ".personas");
858
+ }
859
+
860
+ function ensurePersonasDir(notesDir: string): void {
861
+ const dir = personasDir(notesDir);
862
+ if (!fs.existsSync(dir)) {
863
+ fs.mkdirSync(dir, { recursive: true });
864
+ }
865
+ }
866
+
867
+ function loadInstalledPersonas(notesDir: string): Persona[] {
868
+ const dir = personasDir(notesDir);
869
+ if (!fs.existsSync(dir)) return [];
870
+ const files = fs.readdirSync(dir).filter((f: string) => f.endsWith(".json"));
871
+ const personas: Persona[] = [];
872
+ for (const file of files) {
873
+ try {
874
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
875
+ personas.push(data as Persona);
876
+ } catch {
877
+ // skip malformed files
878
+ }
879
+ }
880
+ return personas;
881
+ }
882
+
883
+ function loadInstalledPersona(notesDir: string, name: string): Persona | null {
884
+ const file = path.join(personasDir(notesDir), `${name}.json`);
885
+ if (!fs.existsSync(file)) return null;
886
+ try {
887
+ return JSON.parse(fs.readFileSync(file, "utf8")) as Persona;
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+
893
+ function validatePersona(data: any): data is Persona {
894
+ return (
895
+ typeof data === "object" && data !== null &&
896
+ typeof data.name === "string" &&
897
+ typeof data.displayName === "string" &&
898
+ typeof data.description === "string" &&
899
+ typeof data.version === "string" &&
900
+ typeof data.author === "string" &&
901
+ typeof data.style === "object" && data.style !== null &&
902
+ typeof data.style.tone === "string" &&
903
+ Array.isArray(data.style.patterns) &&
904
+ Array.isArray(data.examples) &&
905
+ Array.isArray(data.tags)
906
+ );
907
+ }
908
+
909
+ function fetchJson(url: string): Promise<any> {
910
+ return new Promise((resolve, reject) => {
911
+ const parsedUrl = new URL(url);
912
+ const options = {
913
+ hostname: parsedUrl.hostname,
914
+ path: parsedUrl.pathname + parsedUrl.search,
915
+ method: "GET",
916
+ headers: { "Accept": "application/json", "User-Agent": "pressclaw/1.0" },
917
+ };
918
+ const req = https.request(options, (res: any) => {
919
+ let data = "";
920
+ res.on("data", (chunk: string) => (data += chunk));
921
+ res.on("end", () => {
922
+ if (res.statusCode !== 200) {
923
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
924
+ return;
925
+ }
926
+ try {
927
+ resolve(JSON.parse(data));
928
+ } catch {
929
+ reject(new Error(`Invalid JSON response from ${url}`));
930
+ }
931
+ });
932
+ });
933
+ req.on("error", reject);
934
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error("Request timed out")); });
935
+ req.end();
936
+ });
937
+ }
938
+
939
+ function slugifyPersonaName(input: string): string {
940
+ return input
941
+ .toLowerCase()
942
+ .replace(/[^a-z0-9\s-]/g, "")
943
+ .trim()
944
+ .replace(/\s+/g, "-")
945
+ .replace(/-+/g, "-");
946
+ }
947
+
948
+ function updatePerformanceWeights(notesDir: string): void {
949
+ const profilePath = path.join(notesDir, ".style-profile.json");
950
+ if (!fs.existsSync(profilePath)) return;
951
+
952
+ const allFeedback = loadAllFeedback(notesDir);
953
+ if (allFeedback.length < 2) return;
954
+
955
+ // Gather note data with scores
956
+ const noteScores: { slug: string; score: number; tone: string | null; structure: string | null; markers: Record<string, any> | null }[] = [];
957
+ for (const fb of allFeedback) {
958
+ if (fb.aggregate.entries === 0) continue;
959
+ const noteFile = path.join(notesDir, `${fb.slug}.md`);
960
+ if (!fs.existsSync(noteFile)) continue;
961
+ const { meta, body } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
962
+ let markers: Record<string, any> | null = null;
963
+ if (meta.style_markers) {
964
+ try { markers = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers; } catch {}
965
+ }
966
+ if (!markers) markers = extractStyleMarkers(body);
967
+ noteScores.push({
968
+ slug: fb.slug,
969
+ score: fb.aggregate.avgScore,
970
+ tone: meta.tone || null,
971
+ structure: meta.structure || null,
972
+ markers,
973
+ });
974
+ }
975
+
976
+ if (noteScores.length < 2) return;
977
+
978
+ // Find preferred tone (highest avg score)
979
+ const toneScores: Record<string, { total: number; count: number }> = {};
980
+ for (const n of noteScores) {
981
+ if (!n.tone) continue;
982
+ if (!toneScores[n.tone]) toneScores[n.tone] = { total: 0, count: 0 };
983
+ toneScores[n.tone].total += n.score;
984
+ toneScores[n.tone].count += 1;
985
+ }
986
+ const preferredTone = Object.entries(toneScores)
987
+ .map(([tone, d]) => ({ tone, avg: d.total / d.count }))
988
+ .sort((a, b) => b.avg - a.avg)[0]?.tone || "casual";
989
+
990
+ // Find preferred structure
991
+ const structScores: Record<string, { total: number; count: number }> = {};
992
+ for (const n of noteScores) {
993
+ if (!n.structure) continue;
994
+ if (!structScores[n.structure]) structScores[n.structure] = { total: 0, count: 0 };
995
+ structScores[n.structure].total += n.score;
996
+ structScores[n.structure].count += 1;
997
+ }
998
+ const preferredStructure = Object.entries(structScores)
999
+ .map(([structure, d]) => ({ structure, avg: d.total / d.count }))
1000
+ .sort((a, b) => b.avg - a.avg)[0]?.structure || "chunky";
1001
+
1002
+ // Optimal sentence length & word count from top-scoring notes
1003
+ const sorted = [...noteScores].sort((a, b) => b.score - a.score);
1004
+ const topHalf = sorted.slice(0, Math.max(2, Math.ceil(sorted.length / 2)));
1005
+ const sentLengths = topHalf.filter((n) => n.markers?.avgSentenceLength).map((n) => n.markers!.avgSentenceLength);
1006
+ const wordCounts = topHalf.filter((n) => n.markers?.wordCount).map((n) => n.markers!.wordCount);
1007
+
1008
+ const optimalSentenceLength: [number, number] = sentLengths.length > 0
1009
+ ? [Math.round(Math.min(...sentLengths)), Math.round(Math.max(...sentLengths))]
1010
+ : [7, 15];
1011
+ const optimalWordCount: [number, number] = wordCounts.length > 0
1012
+ ? [Math.round(Math.min(...wordCounts)), Math.round(Math.max(...wordCounts))]
1013
+ : [200, 500];
1014
+
1015
+ // Update style profile
1016
+ let profile: any;
1017
+ try { profile = JSON.parse(fs.readFileSync(profilePath, "utf8")); } catch { return; }
1018
+ profile.performanceWeights = {
1019
+ preferredTone,
1020
+ preferredStructure,
1021
+ optimalSentenceLength,
1022
+ optimalWordCount,
1023
+ sampleSize: noteScores.length,
1024
+ };
1025
+ fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2) + "\n", "utf8");
1026
+ }
1027
+
1028
+ // ── Topics ──────────────────────────────────────────────────────────
1029
+
1030
+ interface Topic {
1031
+ id: string;
1032
+ title: string;
1033
+ source?: string;
1034
+ added: string;
1035
+ status: "idea" | "drafted" | "refined" | "published";
1036
+ slug?: string | null;
1037
+ tags?: string[];
1038
+ }
1039
+
1040
+ function topicsPath(notesDir: string) {
1041
+ return path.join(notesDir, "topics.json");
1042
+ }
1043
+
1044
+ function readTopics(notesDir: string): Topic[] {
1045
+ const p = topicsPath(notesDir);
1046
+ if (!fs.existsSync(p)) return [];
1047
+ try {
1048
+ return JSON.parse(fs.readFileSync(p, "utf8"));
1049
+ } catch {
1050
+ return [];
1051
+ }
1052
+ }
1053
+
1054
+ function writeTopics(notesDir: string, topics: Topic[]) {
1055
+ fs.mkdirSync(notesDir, { recursive: true });
1056
+ fs.writeFileSync(topicsPath(notesDir), JSON.stringify(topics, null, 2) + "\n", "utf8");
1057
+ }
1058
+
1059
+ function generateId(): string {
1060
+ return Math.random().toString(36).slice(2, 10);
1061
+ }
1062
+
1063
+ function findTopicByIdOrTitle(topics: Topic[], query: string): Topic | undefined {
1064
+ const byId = topics.find((t) => t.id === query);
1065
+ if (byId) return byId;
1066
+ const lower = query.toLowerCase();
1067
+ return topics.find((t) => t.title.toLowerCase() === lower)
1068
+ || topics.find((t) => t.title.toLowerCase().startsWith(lower));
1069
+ }
1070
+
1071
+ function syncTopicStatus(notesDir: string, topics: Topic[]): Topic[] {
1072
+ let changed = false;
1073
+ for (const topic of topics) {
1074
+ if (!topic.slug) continue;
1075
+ const noteFile = path.join(notesDir, `${topic.slug}.md`);
1076
+ if (!fs.existsSync(noteFile)) continue;
1077
+ const { meta } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
1078
+ let noteStatus: Topic["status"];
1079
+ if (meta.status === "public") noteStatus = "published";
1080
+ else if (meta.status === "refined") noteStatus = "refined";
1081
+ else noteStatus = "drafted";
1082
+ if (topic.status !== noteStatus) {
1083
+ topic.status = noteStatus;
1084
+ changed = true;
1085
+ }
1086
+ }
1087
+ if (changed) writeTopics(notesDir, topics);
1088
+ return topics;
1089
+ }
1090
+
1091
+ function uniqueSlug(base: string, notesDir: string) {
1092
+ let slug = base;
1093
+ let i = 2;
1094
+ while (fs.existsSync(path.join(notesDir, `${slug}.md`))) {
1095
+ slug = `${base}-${i}`;
1096
+ i += 1;
1097
+ }
1098
+ return slug;
1099
+ }
1100
+
1101
+ function escapeHtml(input: string) {
1102
+ return input
1103
+ .replace(/&/g, "&amp;")
1104
+ .replace(/</g, "&lt;")
1105
+ .replace(/>/g, "&gt;")
1106
+ .replace(/"/g, "&quot;")
1107
+ .replace(/'/g, "&#39;");
1108
+ }
1109
+
1110
+ function renderInline(text: string): string {
1111
+ text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
1112
+ text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
1113
+ text = text.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, "<em>$1</em>");
1114
+ text = text.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, "<em>$1</em>");
1115
+ text = text.replace(/`([^`]+?)`/g, "<code>$1</code>");
1116
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1117
+ return text;
1118
+ }
1119
+
1120
+ function renderMarkdown(md: string) {
1121
+ const rawLines = md.split("\n");
1122
+ const blocks: string[] = [];
1123
+ let para: string[] = [];
1124
+ let inCodeBlock = false;
1125
+ let codeLines: string[] = [];
1126
+ let codeLang = "";
1127
+
1128
+ const flushPara = () => {
1129
+ if (para.length === 0) return;
1130
+ blocks.push(`<p>${para.map(renderInline).join(" ")}</p>`);
1131
+ para = [];
1132
+ };
1133
+
1134
+ for (const rawLine of rawLines) {
1135
+ if (/^```/.test(rawLine)) {
1136
+ if (!inCodeBlock) {
1137
+ flushPara();
1138
+ inCodeBlock = true;
1139
+ codeLang = rawLine.replace(/^```/, "").trim();
1140
+ codeLines = [];
1141
+ } else {
1142
+ blocks.push(`<pre><code${codeLang ? ` class="language-${escapeHtml(codeLang)}"` : ""}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
1143
+ inCodeBlock = false;
1144
+ codeLines = [];
1145
+ codeLang = "";
1146
+ }
1147
+ continue;
1148
+ }
1149
+ if (inCodeBlock) {
1150
+ codeLines.push(rawLine);
1151
+ continue;
1152
+ }
1153
+
1154
+ const line = rawLine;
1155
+
1156
+ if (/^---+\s*$/.test(line)) { flushPara(); blocks.push("<hr/>"); continue; }
1157
+ if (/^###\s+/.test(line)) { flushPara(); blocks.push(`<h3>${renderInline(escapeHtml(line.replace(/^###\s+/, "")))}</h3>`); continue; }
1158
+ if (/^##\s+/.test(line)) { flushPara(); blocks.push(`<h2>${renderInline(escapeHtml(line.replace(/^##\s+/, "")))}</h2>`); continue; }
1159
+ if (/^#\s+/.test(line)) { flushPara(); blocks.push(`<h1>${renderInline(escapeHtml(line.replace(/^#\s+/, "")))}</h1>`); continue; }
1160
+ if (/^[\-\*]\s+/.test(line)) { flushPara(); blocks.push(`<li>${renderInline(escapeHtml(line.replace(/^[\-\*]\s+/, "")))}</li>`); continue; }
1161
+ if (/^>\s*/.test(line)) { flushPara(); blocks.push(`<blockquote><p>${renderInline(escapeHtml(line.replace(/^>\s*/, "")))}</p></blockquote>`); continue; }
1162
+ if (line.trim() === "") { flushPara(); continue; }
1163
+ para.push(escapeHtml(line));
1164
+ }
1165
+ flushPara();
1166
+
1167
+ let html = blocks.join("\n");
1168
+ html = html.replace(/(<li>.*?<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
1169
+ return html;
1170
+ }
1171
+
1172
+ function excerptFrom(body: string) {
1173
+ return body
1174
+ .replace(/[#*_`>\[\]]/g, "")
1175
+ .split("\n")
1176
+ .filter((l) => l.trim())
1177
+ .slice(0, 2)
1178
+ .join(" ")
1179
+ .slice(0, 180);
1180
+ }
1181
+
1182
+ async function confirmPublishPrompt(title: string, excerpt: string) {
1183
+ if (!process.stdin.isTTY) {
1184
+ throw new Error("Publish requires confirmation; use --yes in non-interactive mode.");
1185
+ }
1186
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1187
+ const answer = await rl.question(`Publish \"${title}\"? (y/N)\n${excerpt}\n> `);
1188
+ if (!(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes")) {
1189
+ rl.close();
1190
+ return { ok: false } as const;
1191
+ }
1192
+ const reason = await rl.question("Why publish this? (optional)\n> ");
1193
+ rl.close();
1194
+ return { ok: true, reason: reason.trim() } as const;
1195
+ }
1196
+
1197
+ function buildPublic({ notesDir, outputDir, publicPath, siteTitle, authorName, baseUrl }: any) {
1198
+ ensureDirs(outputDir);
1199
+ const files = listNotes(notesDir);
1200
+ const items: { title: string; url: string; date: string; excerpt: string; slug: string; bodyHtml: string }[] = [];
1201
+ for (const file of files) {
1202
+ const full = fs.readFileSync(path.join(notesDir, file), "utf8");
1203
+ const { meta, body } = parseFrontMatter(full);
1204
+ if (meta.status !== "public") continue;
1205
+ const title = meta.title || file.replace(/\.md$/, "");
1206
+ const slug = meta.slug || slugify(title);
1207
+ const date = meta.published_at || new Date().toUTCString();
1208
+ const excerpt = excerptFrom(body);
1209
+ const url = `${publicPath}/${slug}`;
1210
+ const bodyHtml = renderMarkdown(body);
1211
+ items.push({ title, url, date, excerpt, slug, bodyHtml });
1212
+
1213
+ const html = DEFAULT_TEMPLATE(title, bodyHtml, { siteTitle, authorName, date });
1214
+ fs.writeFileSync(path.join(outputDir, "posts", `${slug}.html`), html, "utf8");
1215
+ }
1216
+
1217
+ const indexHtml = INDEX_TEMPLATE(items, { siteTitle });
1218
+ fs.writeFileSync(path.join(outputDir, "index.html"), indexHtml, "utf8");
1219
+ const rssItems = items.map((i) => ({
1220
+ ...i,
1221
+ url: baseUrl ? `${baseUrl}${i.url}` : i.url,
1222
+ }));
1223
+ const rss = rssTemplate(rssItems, { siteTitle, baseUrl });
1224
+ fs.writeFileSync(path.join(outputDir, "rss.xml"), rss, "utf8");
1225
+ }
1226
+
1227
+ // ── Note frontmatter helpers ────────────────────────────────────────
1228
+
1229
+ function newNoteFrontmatter(opts: { title: string; slug: string; topicId?: string; inputType?: string; tags?: string[] }): string {
1230
+ const tags = opts.tags?.length ? `[${opts.tags.map((t) => `"${t}"`).join(", ")}]` : "[]";
1231
+ let fm = `---\ntitle: "${opts.title}"\nslug: "${opts.slug}"\nstatus: private\npublished_at: null\n`;
1232
+ if (opts.topicId) fm += `topic_id: "${opts.topicId}"\n`;
1233
+ if (opts.inputType) fm += `input_type: "${opts.inputType}"\n`;
1234
+ fm += `tone: null\nstructure: null\nconfidence: null\ntags: ${tags}\n---\n`;
1235
+ return fm;
1236
+ }
1237
+
1238
+ // ── Agent Feed Helpers ──────────────────────────────────────────────
1239
+
1240
+ function parseTags(raw: any): string[] {
1241
+ if (!raw) return [];
1242
+ if (Array.isArray(raw)) return raw;
1243
+ if (typeof raw === "string") {
1244
+ const trimmed = raw.trim();
1245
+ if (trimmed.startsWith("[")) {
1246
+ try { return JSON.parse(trimmed); } catch {}
1247
+ }
1248
+ return trimmed.split(",").map((s) => s.trim()).filter(Boolean);
1249
+ }
1250
+ return [];
1251
+ }
1252
+
1253
+ function parseStyleMarkers(raw: any): Record<string, any> | null {
1254
+ if (!raw) return null;
1255
+ if (typeof raw === "object" && !Array.isArray(raw)) return raw;
1256
+ if (typeof raw === "string") {
1257
+ try { return JSON.parse(raw); } catch {}
1258
+ }
1259
+ return null;
1260
+ }
1261
+
1262
+ function generateSummary(body: string): string {
1263
+ const plain = body
1264
+ .replace(/^#+\s+.*/gm, "")
1265
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1266
+ .replace(/\*([^*]+)\*/g, "$1")
1267
+ .replace(/`[^`]+`/g, "")
1268
+ .replace(/```[\s\S]*?```/g, "")
1269
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1270
+ .replace(/^>\s+/gm, "")
1271
+ .replace(/^[-*]\s+/gm, "")
1272
+ .replace(/^\d+\.\s+/gm, "")
1273
+ .replace(/\n+/g, " ")
1274
+ .trim();
1275
+ // First 2 sentences or 200 chars
1276
+ const sentences = plain.match(/[^.!?]*[.!?]/g);
1277
+ if (sentences && sentences.length >= 2) {
1278
+ const twoSentences = sentences.slice(0, 2).join("").trim();
1279
+ if (twoSentences.length <= 300) return twoSentences;
1280
+ }
1281
+ return plain.slice(0, 200).trim() + (plain.length > 200 ? "…" : "");
1282
+ }
1283
+
1284
+ interface AgentFeedPost {
1285
+ slug: string;
1286
+ title: string;
1287
+ published_at: string;
1288
+ url: string;
1289
+ body_markdown: string;
1290
+ summary: string;
1291
+ topics: string[];
1292
+ tags: string[];
1293
+ style_markers: Record<string, any> | null;
1294
+ confidence: number | null;
1295
+ related: string[];
1296
+ reply_to: string | null;
1297
+ conversation_id: string | null;
1298
+ }
1299
+
1300
+ interface AgentFeed {
1301
+ version: string;
1302
+ protocol: string;
1303
+ source: {
1304
+ name: string;
1305
+ url: string;
1306
+ pressclaw_version: string;
1307
+ feed_url: string;
1308
+ };
1309
+ updated_at: string;
1310
+ posts: AgentFeedPost[];
1311
+ }
1312
+
1313
+ interface WellKnown {
1314
+ version: string;
1315
+ name: string;
1316
+ feed_url: string;
1317
+ human_url: string;
1318
+ rss_url: string;
1319
+ post_count: number;
1320
+ topics: string[];
1321
+ updated_at: string;
1322
+ }
1323
+
1324
+ function buildAgentFeed(cfg: { notesDir: string; baseUrl: string; siteTitle: string; publicPath: string }): AgentFeed {
1325
+ const files = listNotes(cfg.notesDir);
1326
+ const posts: AgentFeedPost[] = [];
1327
+
1328
+ for (const file of files) {
1329
+ const full = fs.readFileSync(path.join(cfg.notesDir, file), "utf8");
1330
+ const { meta, body } = parseFrontMatter(full);
1331
+ if (meta.status !== "public") continue;
1332
+
1333
+ const slug = meta.slug || file.replace(/\.md$/, "");
1334
+ const title = meta.title || slug;
1335
+ const tags = parseTags(meta.tags);
1336
+ const sm = parseStyleMarkers(meta.style_markers);
1337
+ const conf = meta.confidence ? parseFloat(meta.confidence) : null;
1338
+
1339
+ posts.push({
1340
+ slug,
1341
+ title,
1342
+ published_at: meta.published_at || new Date().toISOString(),
1343
+ url: `${cfg.baseUrl}${cfg.publicPath}/${slug}`,
1344
+ body_markdown: body,
1345
+ summary: generateSummary(body),
1346
+ topics: tags, // derive from tags for now
1347
+ tags,
1348
+ style_markers: sm ? {
1349
+ tone: meta.tone || sm.tone || "authentic",
1350
+ structure: meta.structure || sm.structure || "structured",
1351
+ readability: sm.readabilityScore ?? sm.readability ?? null,
1352
+ wordCount: sm.wordCount ?? null,
1353
+ avgSentenceLength: sm.avgSentenceLength ?? null,
1354
+ perspective: sm.perspective ?? "mixed",
1355
+ } : null,
1356
+ confidence: isNaN(conf as number) ? null : conf,
1357
+ related: [],
1358
+ reply_to: meta.reply_to || null,
1359
+ conversation_id: meta.conversation_id || null,
1360
+ });
1361
+ }
1362
+
1363
+ // Sort by published_at descending
1364
+ posts.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
1365
+
1366
+ const updatedAt = posts.length > 0 ? posts[0].published_at : new Date().toISOString();
1367
+
1368
+ return {
1369
+ version: "0.4",
1370
+ protocol: "pressclaw-agent-feed",
1371
+ source: {
1372
+ name: cfg.siteTitle,
1373
+ url: cfg.baseUrl,
1374
+ pressclaw_version: "0.3.0",
1375
+ feed_url: `${cfg.baseUrl}/feed/agent.json`,
1376
+ },
1377
+ updated_at: updatedAt,
1378
+ posts,
1379
+ };
1380
+ }
1381
+
1382
+ function buildWellKnown(cfg: { notesDir: string; baseUrl: string; siteTitle: string; publicPath: string }): WellKnown {
1383
+ const feed = buildAgentFeed(cfg);
1384
+ const allTopics = new Set<string>();
1385
+ for (const post of feed.posts) {
1386
+ for (const t of post.topics) allTopics.add(t);
1387
+ }
1388
+
1389
+ return {
1390
+ version: "0.4",
1391
+ name: cfg.siteTitle,
1392
+ feed_url: `${cfg.baseUrl}/feed/agent.json`,
1393
+ human_url: `${cfg.baseUrl}${cfg.publicPath}`,
1394
+ rss_url: `${cfg.baseUrl}${cfg.publicPath}/rss.xml`,
1395
+ post_count: feed.posts.length,
1396
+ topics: Array.from(allTopics),
1397
+ updated_at: feed.updated_at,
1398
+ };
1399
+ }
1400
+
1401
+ interface Subscription {
1402
+ url: string;
1403
+ name: string;
1404
+ feed_url: string;
1405
+ added: string;
1406
+ last_checked: string | null;
1407
+ }
1408
+
1409
+ function subscriptionsPath(notesDir: string): string {
1410
+ return path.join(notesDir, ".subscriptions.json");
1411
+ }
1412
+
1413
+ function readSubscriptions(notesDir: string): Subscription[] {
1414
+ const p = subscriptionsPath(notesDir);
1415
+ if (!fs.existsSync(p)) return [];
1416
+ try {
1417
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
1418
+ return data.subscriptions || [];
1419
+ } catch {
1420
+ return [];
1421
+ }
1422
+ }
1423
+
1424
+ function writeSubscriptions(notesDir: string, subs: Subscription[]): void {
1425
+ fs.mkdirSync(notesDir, { recursive: true });
1426
+ fs.writeFileSync(
1427
+ subscriptionsPath(notesDir),
1428
+ JSON.stringify({ subscriptions: subs }, null, 2) + "\n",
1429
+ "utf8"
1430
+ );
1431
+ }
1432
+
1433
+ // ── Plugin ──────────────────────────────────────────────────────────
1434
+
1435
+ export default function register(api: any) {
1436
+ const pluginDir = __dirname;
1437
+
1438
+ api.registerCli(({ program }: any) => {
1439
+ const notes = program.command("notes");
1440
+
1441
+ // ── default (dashboard) ────────────────────────────────────────
1442
+ notes.action(() => {
1443
+ const cfg = resolveConfig(api);
1444
+ const files = listNotes(cfg.notesDir);
1445
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
1446
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1447
+
1448
+ const ideas = topics.filter((t) => t.status === "idea");
1449
+ const drafted = topics.filter((t) => t.status === "drafted");
1450
+ const refined = topics.filter((t) => t.status === "refined");
1451
+ const published = topics.filter((t) => t.status === "published");
1452
+
1453
+ console.log(`\n📊 Notes Dashboard`);
1454
+ console.log(` ${ideas.length} ideas · ${drafted.length} drafted · ${refined.length} refined · ${published.length} published · ${files.length} files`);
1455
+ if (styleProfile) {
1456
+ console.log(` 🎨 Style profile: updated ${styleProfile.updated} (${styleProfile.analyzedNotes.length} notes analyzed)`);
1457
+ } else {
1458
+ console.log(` 🎨 No style profile yet — run: openclaw notes style`);
1459
+ }
1460
+ console.log();
1461
+
1462
+ if (published.length > 0) {
1463
+ console.log(`📢 Published:`);
1464
+ for (const t of published) {
1465
+ const conf = _noteConfidence(cfg.notesDir, t.slug);
1466
+ console.log(` ${t.title} → ${t.slug}${conf !== null ? ` (confidence: ${conf}/10)` : ""}`);
1467
+ }
1468
+ console.log();
1469
+ }
1470
+
1471
+ if (refined.length > 0) {
1472
+ console.log(`✨ Refined (ready to publish):`);
1473
+ for (const t of refined) {
1474
+ const conf = _noteConfidence(cfg.notesDir, t.slug);
1475
+ console.log(` ${t.title} → ${t.slug}.md${conf !== null ? ` (confidence: ${conf}/10)` : ""}`);
1476
+ }
1477
+ console.log();
1478
+ }
1479
+
1480
+ if (drafted.length > 0) {
1481
+ console.log(`📝 Drafted:`);
1482
+ for (const t of drafted) {
1483
+ const varCount = _variationCount(cfg.notesDir, t.slug);
1484
+ console.log(` ${t.title} → ${t.slug}.md${varCount > 0 ? ` (${varCount} variations)` : ""}`);
1485
+ }
1486
+ console.log();
1487
+ }
1488
+
1489
+ const trackedSlugs = new Set(topics.map((t) => t.slug).filter(Boolean));
1490
+ const orphans = files.filter((f) => !trackedSlugs.has(f.replace(/\.md$/, "")));
1491
+ if (orphans.length > 0) {
1492
+ console.log(`📄 Untracked notes:`);
1493
+ for (const f of orphans) {
1494
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
1495
+ const { meta } = parseFrontMatter(full);
1496
+ const status = meta.status === "public" ? "📢" : meta.status === "refined" ? "✨" : "🔒";
1497
+ const title = meta.title || f.replace(/\.md$/, "");
1498
+ console.log(` ${status} ${title} (${f})`);
1499
+ }
1500
+ console.log();
1501
+ }
1502
+
1503
+ if (ideas.length > 0) {
1504
+ console.log(`💡 Ideas (${ideas.length}):`);
1505
+ for (const t of ideas) {
1506
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
1507
+ console.log(` ${t.id} ${t.title}${tags}`);
1508
+ }
1509
+ console.log();
1510
+ }
1511
+
1512
+ if (files.length === 0 && topics.length === 0) {
1513
+ console.log(` Empty! Run: openclaw notes init`);
1514
+ }
1515
+ });
1516
+
1517
+ // ── init ───────────────────────────────────────────────────────
1518
+ notes.command("init").action(() => {
1519
+ const cfg = resolveConfig(api);
1520
+ ensureDirs(cfg.outputDir);
1521
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
1522
+
1523
+ const existing = listNotes(cfg.notesDir);
1524
+ if (existing.length === 0) {
1525
+ const templateSrc = path.join(pluginDir, "templates", "default.md");
1526
+ if (fs.existsSync(templateSrc)) {
1527
+ const dest = path.join(cfg.notesDir, "your-first-note.md");
1528
+ fs.copyFileSync(templateSrc, dest);
1529
+ console.log(` Created example note: ${dest}`);
1530
+ }
1531
+ }
1532
+
1533
+ console.log(`\n✅ Public Thinking initialized!`);
1534
+ console.log(` Notes directory: ${cfg.notesDir}`);
1535
+ console.log(` Output directory: ${cfg.outputDir}`);
1536
+ console.log(`\nNext steps:`);
1537
+ console.log(` 1. Create a note: openclaw notes new "My First Idea"`);
1538
+ console.log(` 2. Edit the .md file in your notes directory`);
1539
+ console.log(` 3. Publish it: openclaw notes publish my-first-idea`);
1540
+ if (cfg.dailyPrompt.enabled) {
1541
+ console.log(`\n📅 Daily prompt is enabled. Run this to set up the cron job:`);
1542
+ console.log(` openclaw notes setup`);
1543
+ }
1544
+ });
1545
+
1546
+ // ── setup ──────────────────────────────────────────────────────
1547
+ notes.command("setup").action(async () => {
1548
+ const cfg = resolveConfig(api);
1549
+
1550
+ if (!cfg.dailyPrompt.enabled) {
1551
+ console.log("Daily prompt is disabled in config. Set dailyPrompt.enabled to true to use this.");
1552
+ return;
1553
+ }
1554
+
1555
+ try {
1556
+ const cronList = await api.cron?.list?.();
1557
+ if (cronList && Array.isArray(cronList)) {
1558
+ const existing = cronList.find((j: any) => j.name === "pressclaw-daily");
1559
+ if (existing) {
1560
+ console.log("✅ Cron job 'pressclaw-daily' already exists. Skipping.");
1561
+ console.log(` Schedule: ${existing.schedule || existing.cron || cfg.dailyPrompt.schedule}`);
1562
+ return;
1563
+ }
1564
+ }
1565
+ } catch { }
1566
+
1567
+ try {
1568
+ await api.cron?.create?.({
1569
+ name: "pressclaw-daily",
1570
+ schedule: cfg.dailyPrompt.schedule,
1571
+ timezone: cfg.dailyPrompt.timezone,
1572
+ prompt: cfg.dailyPrompt.prompt,
1573
+ });
1574
+ console.log(`✅ Daily prompt cron job created!`);
1575
+ console.log(` Name: pressclaw-daily`);
1576
+ console.log(` Schedule: ${cfg.dailyPrompt.schedule}`);
1577
+ console.log(` Timezone: ${cfg.dailyPrompt.timezone}`);
1578
+ } catch (err: any) {
1579
+ console.error(`Failed to create cron job via API. You can create it manually:\n`);
1580
+ console.log(` openclaw cron create \\`);
1581
+ console.log(` --name "pressclaw-daily" \\`);
1582
+ console.log(` --schedule "${cfg.dailyPrompt.schedule}" \\`);
1583
+ console.log(` --timezone "${cfg.dailyPrompt.timezone}" \\`);
1584
+ console.log(` --prompt '${cfg.dailyPrompt.prompt.replace(/'/g, "'\\''")}'`);
1585
+ if (err?.message) console.error(`\nError: ${err.message}`);
1586
+ }
1587
+ });
1588
+
1589
+ // ── new ────────────────────────────────────────────────────────
1590
+ notes.command("new <title>").action((title: string) => {
1591
+ const cfg = resolveConfig(api);
1592
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
1593
+ const base = slugify(title);
1594
+ const slug = uniqueSlug(base, cfg.notesDir);
1595
+ const content = newNoteFrontmatter({ title, slug }) + `\n# ${title}\n\n`;
1596
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
1597
+ fs.writeFileSync(filename, content, "utf8");
1598
+ console.log(`Created ${filename}`);
1599
+ });
1600
+
1601
+ // ── publish ────────────────────────────────────────────────────
1602
+ notes
1603
+ .command("publish <slug>")
1604
+ .option("-y, --yes", "skip confirmation")
1605
+ .option("-r, --reason <text>", "reason for publishing")
1606
+ .action(async (slug: string, options: any) => {
1607
+ const cfg = resolveConfig(api);
1608
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1609
+ const full = fs.readFileSync(file, "utf8");
1610
+ const { meta, body } = parseFrontMatter(full);
1611
+ const title = meta.title || slug;
1612
+ const excerpt = excerptFrom(body);
1613
+ let publishReason = options.reason || "";
1614
+
1615
+ if (!options.yes) {
1616
+ const result = await confirmPublishPrompt(title, excerpt);
1617
+ if (!result.ok) {
1618
+ console.log("Publish canceled.");
1619
+ return; }
1620
+ if (!publishReason) publishReason = result.reason || "";
1621
+ }
1622
+
1623
+ // Extract style markers before writing
1624
+ const markers = extractStyleMarkers(body);
1625
+ const pubMeta: Record<string, any> = {
1626
+ title,
1627
+ slug: meta.slug || slug,
1628
+ status: "public",
1629
+ published_at: new Date().toUTCString(),
1630
+ publish_reason: publishReason.replace(/"/g, '\\"'),
1631
+ topic_id: meta.topic_id || null,
1632
+ tone: meta.tone || null,
1633
+ structure: meta.structure || null,
1634
+ confidence: meta.confidence || null,
1635
+ tags: meta.tags || "[]",
1636
+ };
1637
+ const updated = writeFrontmatterWithMarkers(pubMeta, body, markers);
1638
+ fs.writeFileSync(file, updated, "utf8");
1639
+ buildPublic(cfg);
1640
+
1641
+ // Auto-update aggregate style profile
1642
+ const profileResult = updateAggregateProfile(cfg.notesDir);
1643
+ if (profileResult.updated) {
1644
+ console.log(`🎨 Style profile auto-updated (${profileResult.noteCount} notes analyzed)`);
1645
+ }
1646
+
1647
+ const topics = readTopics(cfg.notesDir);
1648
+ const linked = topics.find((t) => t.slug === (meta.slug || slug));
1649
+ if (linked && linked.status !== "published") {
1650
+ linked.status = "published";
1651
+ writeTopics(cfg.notesDir, topics);
1652
+ }
1653
+
1654
+ console.log(`Published ${slug}`);
1655
+ });
1656
+
1657
+ // ── unpublish ──────────────────────────────────────────────────
1658
+ notes.command("unpublish <slug>").action((slug: string) => {
1659
+ const cfg = resolveConfig(api);
1660
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1661
+ const full = fs.readFileSync(file, "utf8");
1662
+ const { meta, body } = parseFrontMatter(full);
1663
+ const updated = `---\ntitle: "${meta.title || slug}"\nslug: "${meta.slug || slug}"\nstatus: private\npublished_at: ${meta.published_at || "null"}\ntone: ${meta.tone || "null"}\nstructure: ${meta.structure || "null"}\nconfidence: ${meta.confidence || "null"}\ntags: ${meta.tags || "[]"}\n---\n\n${body}\n`;
1664
+ fs.writeFileSync(file, updated, "utf8");
1665
+ const publicFile = path.join(cfg.outputDir, "posts", `${slug}.html`);
1666
+ if (fs.existsSync(publicFile)) fs.unlinkSync(publicFile);
1667
+ buildPublic(cfg);
1668
+ console.log(`Unpublished ${slug}`);
1669
+ });
1670
+
1671
+ // ── build ──────────────────────────────────────────────────────
1672
+ notes.command("build").action(() => {
1673
+ const cfg = resolveConfig(api);
1674
+ buildPublic(cfg);
1675
+
1676
+ // Generate agent feed
1677
+ if (cfg.baseUrl) {
1678
+ const feedDir = path.join(cfg.outputDir, "feed");
1679
+ fs.mkdirSync(feedDir, { recursive: true });
1680
+ const agentFeed = buildAgentFeed(cfg);
1681
+ fs.writeFileSync(path.join(feedDir, "agent.json"), JSON.stringify(agentFeed, null, 2) + "\n", "utf8");
1682
+
1683
+ const wellKnownDir = path.join(cfg.outputDir, ".well-known");
1684
+ fs.mkdirSync(wellKnownDir, { recursive: true });
1685
+ const wellKnown = buildWellKnown(cfg);
1686
+ fs.writeFileSync(path.join(wellKnownDir, "pressclaw.json"), JSON.stringify(wellKnown, null, 2) + "\n", "utf8");
1687
+
1688
+ console.log("Built public stream + RSS + agent feed");
1689
+ } else {
1690
+ console.log("Built public stream + RSS (set baseUrl in config for agent feed)");
1691
+ }
1692
+ });
1693
+
1694
+ // ── list ───────────────────────────────────────────────────────
1695
+ notes.command("list").action(() => {
1696
+ const cfg = resolveConfig(api);
1697
+ const files = listNotes(cfg.notesDir);
1698
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
1699
+
1700
+ if (files.length === 0 && topics.length === 0) {
1701
+ console.log("No notes or topics found. Run `openclaw notes init` to get started.");
1702
+ return;
1703
+ }
1704
+
1705
+ if (files.length > 0) {
1706
+ console.log("\n📝 Notes:");
1707
+ for (const f of files) {
1708
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
1709
+ const { meta } = parseFrontMatter(full);
1710
+ const status = meta.status === "public" ? "📢" : meta.status === "refined" ? "✨" : "🔒";
1711
+ const title = meta.title || f.replace(/\.md$/, "");
1712
+ console.log(` ${status} ${f.padEnd(35)} ${title}`);
1713
+ }
1714
+ }
1715
+
1716
+ const ideas = topics.filter((t) => t.status === "idea");
1717
+ if (ideas.length > 0) {
1718
+ console.log("\n💡 Topics (ideas — no draft yet):");
1719
+ for (const t of ideas) {
1720
+ const src = t.source ? ` (${t.source})` : "";
1721
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
1722
+ console.log(` ${t.id} ${t.title}${src}${tags}`);
1723
+ }
1724
+ }
1725
+ });
1726
+
1727
+ // ── topics ─────────────────────────────────────────────────────
1728
+ const topicsCmd = notes.command("topics").description("Manage topic backlog");
1729
+
1730
+ topicsCmd.action(() => {
1731
+ const cfg = resolveConfig(api);
1732
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
1733
+ if (topics.length === 0) {
1734
+ console.log("No topics yet. Add one with: openclaw notes topics add \"Title\"");
1735
+ return;
1736
+ }
1737
+
1738
+ const ideas = topics.filter((t) => t.status === "idea");
1739
+ const drafted = topics.filter((t) => t.status === "drafted");
1740
+ const refined = topics.filter((t) => t.status === "refined");
1741
+ const published = topics.filter((t) => t.status === "published");
1742
+
1743
+ if (ideas.length > 0) {
1744
+ console.log("\n💡 Ideas:");
1745
+ for (const t of ideas) {
1746
+ const src = t.source ? ` — ${t.source}` : "";
1747
+ const tags = t.tags?.length ? ` [${t.tags.join(", ")}]` : "";
1748
+ console.log(` ${t.id} ${t.title}${src}${tags} (${t.added})`);
1749
+ }
1750
+ }
1751
+ if (drafted.length > 0) {
1752
+ console.log("\n📝 Drafted:");
1753
+ for (const t of drafted) console.log(` ${t.id} ${t.title} → ${t.slug}.md`);
1754
+ }
1755
+ if (refined.length > 0) {
1756
+ console.log("\n✨ Refined:");
1757
+ for (const t of refined) console.log(` ${t.id} ${t.title} → ${t.slug}.md`);
1758
+ }
1759
+ if (published.length > 0) {
1760
+ console.log("\n📢 Published:");
1761
+ for (const t of published) console.log(` ${t.id} ${t.title} → ${t.slug}.md`);
1762
+ }
1763
+ });
1764
+
1765
+ topicsCmd
1766
+ .command("add <title>")
1767
+ .option("-s, --source <text>", "where the idea came from")
1768
+ .option("-t, --tags <tags>", "comma-separated tags")
1769
+ .action((title: string, options: any) => {
1770
+ const cfg = resolveConfig(api);
1771
+ const topics = readTopics(cfg.notesDir);
1772
+ const topic: Topic = {
1773
+ id: generateId(),
1774
+ title,
1775
+ source: options.source || undefined,
1776
+ added: new Date().toISOString().split("T")[0],
1777
+ status: "idea",
1778
+ slug: null,
1779
+ tags: options.tags ? options.tags.split(",").map((s: string) => s.trim()) : undefined,
1780
+ };
1781
+ topics.push(topic);
1782
+ writeTopics(cfg.notesDir, topics);
1783
+ console.log(`Added topic ${topic.id}: "${title}"`);
1784
+ });
1785
+
1786
+ topicsCmd.command("draft <query>").action((query: string) => {
1787
+ const cfg = resolveConfig(api);
1788
+ const topics = readTopics(cfg.notesDir);
1789
+ const topic = findTopicByIdOrTitle(topics, query);
1790
+ if (!topic) { console.log(`Topic not found: "${query}". Use 'openclaw notes topics' to see available topics.`); return; }
1791
+ if (topic.status !== "idea") { console.log(`Topic "${topic.title}" already has status "${topic.status}" → ${topic.slug}.md`); return; }
1792
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
1793
+ const base = slugify(topic.title);
1794
+ const slug = uniqueSlug(base, cfg.notesDir);
1795
+ const content = newNoteFrontmatter({ title: topic.title, slug, topicId: topic.id, tags: topic.tags }) + `\n# ${topic.title}\n\n`;
1796
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
1797
+ fs.writeFileSync(filename, content, "utf8");
1798
+ topic.status = "drafted";
1799
+ topic.slug = slug;
1800
+ writeTopics(cfg.notesDir, topics);
1801
+ console.log(`Created ${filename} from topic "${topic.title}"`);
1802
+ });
1803
+
1804
+ topicsCmd.command("remove <query>").action((query: string) => {
1805
+ const cfg = resolveConfig(api);
1806
+ const topics = readTopics(cfg.notesDir);
1807
+ const topic = findTopicByIdOrTitle(topics, query);
1808
+ if (!topic) { console.log(`Topic not found: "${query}".`); return; }
1809
+ const filtered = topics.filter((t) => t.id !== topic.id);
1810
+ writeTopics(cfg.notesDir, filtered);
1811
+ console.log(`Removed topic ${topic.id}: "${topic.title}"`);
1812
+ });
1813
+
1814
+ topicsCmd
1815
+ .command("scan")
1816
+ .option("--suggest", "output a prompt for the agent to evaluate and propose topics")
1817
+ .action((options: any) => {
1818
+ const cfg = resolveConfig(api);
1819
+ const workspace = resolveWorkspace(api);
1820
+ const excludeDirs = new Set([path.resolve(cfg.notesDir), path.resolve(cfg.outputDir)]);
1821
+ const excludeDirNames = new Set(["node_modules", ".git", ".cache"]);
1822
+ interface ScannedFile { relPath: string; title: string; excerpt: string; sizeBytes: number; }
1823
+ const scanned: ScannedFile[] = [];
1824
+ const dirsSeen = new Set<string>();
1825
+
1826
+ function walkDir(dir: string) {
1827
+ let entries: string[];
1828
+ try { entries = fs.readdirSync(dir); } catch { return; }
1829
+ for (const entry of entries) {
1830
+ const fullPath = path.join(dir, entry);
1831
+ let stat: fs.Stats;
1832
+ try { stat = fs.statSync(fullPath); } catch { continue; }
1833
+ if (stat.isDirectory()) {
1834
+ if (excludeDirNames.has(entry)) continue;
1835
+ if (excludeDirs.has(path.resolve(fullPath))) continue;
1836
+ walkDir(fullPath);
1837
+ } else if (stat.isFile() && entry.endsWith(".md")) {
1838
+ const relPath = path.relative(workspace, fullPath);
1839
+ dirsSeen.add(path.dirname(relPath));
1840
+ let content: string;
1841
+ try { content = fs.readFileSync(fullPath, "utf8"); } catch { continue; }
1842
+ let title = entry.replace(/\.md$/, "");
1843
+ const headingMatch = content.match(/^#\s+(.+)$/m);
1844
+ if (headingMatch) title = headingMatch[1].trim();
1845
+ else { const { meta } = parseFrontMatter(content); if (meta.title) title = meta.title; }
1846
+ const { body } = parseFrontMatter(content);
1847
+ const plainBody = body.replace(/^#+\s+.*$/gm, "").replace(/[#*_`>\[\]]/g, "").trim();
1848
+ const excerpt = plainBody.slice(0, 200).replace(/\n/g, " ").trim();
1849
+ scanned.push({ relPath, title, excerpt, sizeBytes: stat.size });
1850
+ }
1851
+ }
1852
+ }
1853
+ walkDir(workspace);
1854
+
1855
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
1856
+ const noteFiles = listNotes(cfg.notesDir);
1857
+ const trackedSources = new Set<string>();
1858
+ for (const topic of topics) {
1859
+ if (topic.source) trackedSources.add(topic.source);
1860
+ if (topic.slug) {
1861
+ trackedSources.add(`${topic.slug}.md`);
1862
+ trackedSources.add(path.relative(workspace, path.join(cfg.notesDir, `${topic.slug}.md`)));
1863
+ }
1864
+ }
1865
+ for (const nf of noteFiles) trackedSources.add(path.relative(workspace, path.join(cfg.notesDir, nf)));
1866
+ const untracked = scanned.filter((f) => !trackedSources.has(f.relPath));
1867
+ const ideas = topics.filter((t) => t.status === "idea").length;
1868
+ const drafted = topics.filter((t) => t.status === "drafted").length;
1869
+ const refined = topics.filter((t) => t.status === "refined").length;
1870
+ const published = topics.filter((t) => t.status === "published").length;
1871
+ function formatSize(bytes: number): string { return bytes < 1024 ? `${bytes}B` : `${(bytes / 1024).toFixed(1)}KB`; }
1872
+
1873
+ if (options.suggest) {
1874
+ console.log(`🤖 Agent review requested. Evaluate these workspace files for publishable topics.`);
1875
+ console.log(`Consider: What insights, lessons, or ideas here would be interesting as standalone blog posts?`);
1876
+ console.log(`Only suggest topics that are atomic (one idea per post) and would be valuable to a general audience.\n`);
1877
+ console.log(`Already tracking ${topics.length} topics (${ideas} ideas, ${drafted} drafted, ${refined} refined, ${published} published).\n`);
1878
+ if (untracked.length === 0) { console.log(`All ${scanned.length} workspace markdown files are already tracked. Nothing new to suggest.`); return; }
1879
+ for (const f of untracked) {
1880
+ console.log(` ${f.relPath} — "${f.title}" (${formatSize(f.sizeBytes)})`);
1881
+ if (f.excerpt) console.log(` ${f.excerpt.slice(0, 120)}${f.excerpt.length > 120 ? "…" : ""}`);
1882
+ }
1883
+ console.log(`\nFor each suggestion, run:\n openclaw notes topics add "Title" --source "file.md" --tags "tag1,tag2"`);
1884
+ } else {
1885
+ console.log(`📂 Workspace scan complete\n`);
1886
+ console.log(`Found ${scanned.length} markdown files across ${dirsSeen.size} directories.`);
1887
+ console.log(`Already tracking ${topics.length} topics (${ideas} ideas, ${drafted} drafted, ${refined} refined, ${published} published).\n`);
1888
+ if (untracked.length === 0) { console.log(`✅ All workspace markdown files are already tracked or part of the notes pipeline.`); return; }
1889
+ console.log(`📄 Untracked files (not linked to any topic):`);
1890
+ for (const f of untracked) console.log(` ${f.relPath} — "${f.title}" (${formatSize(f.sizeBytes)})`);
1891
+ console.log(`\nTo add a topic from this scan:\n openclaw notes topics add "Title" --source "path/to/source.md"`);
1892
+ }
1893
+ });
1894
+
1895
+ // ── transform ──────────────────────────────────────────────────
1896
+ notes
1897
+ .command("transform <slug>")
1898
+ .option("--tone <tone>", "target tone: authentic | professional | casual | humorous")
1899
+ .option("--structure <structure>", "target structure: structured | list | chunky | minimal | storytelling | thread | tldr")
1900
+ .action((slug: string, options: any) => {
1901
+ const cfg = resolveConfig(api);
1902
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1903
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
1904
+ const full = fs.readFileSync(file, "utf8");
1905
+ const { meta, body } = parseFrontMatter(full);
1906
+ const title = meta.title || slug;
1907
+ const wordCount = body.split(/\s+/).filter((w: string) => w.length > 0).length;
1908
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1909
+ const tone = options.tone || meta.tone || "authentic";
1910
+ const structure = options.structure || meta.structure || "structured";
1911
+ const structureTpl = getStructureTemplate(pluginDir, structure);
1912
+
1913
+ console.log(`✨ Transform: "${title}"`);
1914
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1915
+ console.log(`Tone: ${tone} | Structure: ${structure}`);
1916
+ if (styleProfile) console.log(`Style profile loaded (${styleProfile.analyzedNotes.length} notes analyzed)`);
1917
+ console.log();
1918
+ console.log(`Original (${wordCount} words):`);
1919
+ console.log(body);
1920
+ console.log();
1921
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1922
+ console.log(`Transform this note into a polished blog post.\n`);
1923
+
1924
+ // Style profile injection
1925
+ if (styleProfile) {
1926
+ console.log(`🎨 STYLE PROFILE — Maintain this voice:`);
1927
+ console.log(` ${styleProfile.voiceDescription}`);
1928
+ if (styleProfile.avoid?.length) console.log(` Avoid: ${styleProfile.avoid.join(", ")}`);
1929
+ if (styleProfile.examples?.strongOpeners?.length) console.log(` Example opener: "${styleProfile.examples.strongOpeners[0]}"`);
1930
+ console.log();
1931
+ }
1932
+
1933
+ // Structure template injection
1934
+ if (structureTpl) {
1935
+ console.log(`📋 STRUCTURE — ${structureTpl.name}: ${structureTpl.description}`);
1936
+ console.log(` ${structureTpl.instructions}`);
1937
+ console.log();
1938
+ }
1939
+
1940
+ // Tone instruction
1941
+ console.log(`🎭 TONE — ${tone}:`);
1942
+ const toneInstructions: Record<string, string> = {
1943
+ authentic: "Preserve the author's natural voice. Mirror their tone whether formal or casual.",
1944
+ professional: "Use formal, business-appropriate language. Be clear, authoritative, and polished.",
1945
+ casual: "Use friendly, conversational language. Be approachable and relatable.",
1946
+ humorous: "Add light humor where appropriate. Be witty but not at the expense of clarity.",
1947
+ };
1948
+ console.log(` ${toneInstructions[tone] || toneInstructions.authentic}`);
1949
+ console.log();
1950
+
1951
+ console.log(`Content Guidelines:`);
1952
+ console.log(`- Keep it atomic: one clear idea per post`);
1953
+ console.log(`- Strong opener that hooks the reader`);
1954
+ console.log(`- Clear takeaway at the end`);
1955
+ console.log(`- Code examples should be minimal and illustrative`);
1956
+ console.log();
1957
+ console.log(`Personal Branding Guidelines:`);
1958
+ console.log(`- Position as expertise/insight, not company promotion`);
1959
+ console.log(`- Lead with the principle, not the project`);
1960
+ console.log(`- Show earned expertise: "I built this and here's what I learned"`);
1961
+ console.log(`- End with a clear, shareable takeaway`);
1962
+ console.log(`- Avoid: humble brags, buzzwords, vague claims`);
1963
+ console.log();
1964
+ console.log(`After transforming, update the note file (set tone: ${tone}, structure: ${structure} in frontmatter) and run:`);
1965
+ console.log(` openclaw notes refine ${slug}`);
1966
+ });
1967
+
1968
+ // ── adapt (platform adaptation) ────────────────────────────────
1969
+ notes
1970
+ .command("adapt <slug>")
1971
+ .option("--platform <platform>", "target platform: linkedin | twitter | thread", "linkedin")
1972
+ .action((slug: string, options: any) => {
1973
+ const cfg = resolveConfig(api);
1974
+ const file = path.join(cfg.notesDir, `${slug}.md`);
1975
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
1976
+ const full = fs.readFileSync(file, "utf8");
1977
+ const { meta, body } = parseFrontMatter(full);
1978
+ const title = meta.title || slug;
1979
+ const wordCount = body.split(/\s+/).filter((w: string) => w.length > 0).length;
1980
+ const platform = (options.platform || "linkedin").toLowerCase();
1981
+ const styleProfile = loadStyleProfile(cfg.notesDir);
1982
+
1983
+ // Load named persona if tone references one
1984
+ let namedPersona: any = null;
1985
+ if (meta.tone && meta.tone !== "authentic" && meta.tone !== "professional" && meta.tone !== "casual" && meta.tone !== "humorous") {
1986
+ const personaPath = path.join(cfg.notesDir, ".style-profiles", `${meta.tone}.json`);
1987
+ if (fs.existsSync(personaPath)) {
1988
+ try { namedPersona = JSON.parse(fs.readFileSync(personaPath, "utf8")); } catch {}
1989
+ }
1990
+ }
1991
+
1992
+ const platformSpecs: Record<string, {
1993
+ name: string;
1994
+ maxChars: number;
1995
+ format: string;
1996
+ conventions: string[];
1997
+ structure: string;
1998
+ example: string;
1999
+ }> = {
2000
+ linkedin: {
2001
+ name: "LinkedIn",
2002
+ maxChars: 3000,
2003
+ format: "post",
2004
+ conventions: [
2005
+ "Hook in the first 2 lines (before 'see more' fold)",
2006
+ "Short paragraphs (1-2 sentences each) with line breaks between",
2007
+ "Use line breaks generously — walls of text get scrolled past",
2008
+ "No markdown formatting — LinkedIn doesn't render it",
2009
+ "Bold-like emphasis: use CAPS sparingly or ↳ arrows for sub-points",
2010
+ "End with a question or call-to-action to drive comments",
2011
+ "No hashtags in the body — add 3-5 relevant ones at the very end",
2012
+ "No links in the body (kills reach) — put link in first comment instead",
2013
+ "Mention 'link in comments' if referencing external content",
2014
+ ],
2015
+ structure: "Hook (1-2 lines) → Context (2-3 short ¶) → Core insight (2-3 short ¶) → Takeaway or question → Hashtags",
2016
+ 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 — they're having two different conversations.\n\n↳ Argue capital, not time\n↳ Reframe dilution as value creation\n↳ Bundle everything into one deal\n↳ 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",
2017
+ },
2018
+ twitter: {
2019
+ name: "Twitter/X",
2020
+ maxChars: 280,
2021
+ format: "single tweet",
2022
+ conventions: [
2023
+ "Maximum 280 characters — every word must earn its place",
2024
+ "No hashtags unless absolutely essential (max 1-2)",
2025
+ "Strong opinion or surprising take as the hook",
2026
+ "No links (kills reach) — save for reply",
2027
+ "Use numbers and specifics for credibility",
2028
+ "End with a punchy line, not a question",
2029
+ "No emojis unless they add meaning",
2030
+ "Write like you'd text a smart friend",
2031
+ ],
2032
+ structure: "Hook/take → Supporting evidence or example → Punchline",
2033
+ 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.",
2034
+ },
2035
+ thread: {
2036
+ name: "Twitter/X Thread",
2037
+ maxChars: 280,
2038
+ format: "numbered thread (each tweet ≤280 chars)",
2039
+ conventions: [
2040
+ "First tweet must hook — it carries the thread",
2041
+ "Number each tweet: 1/, 2/, 3/ etc.",
2042
+ "Each tweet should be self-contained AND flow into the next",
2043
+ "5-10 tweets is the sweet spot (more = drop-off)",
2044
+ "Last tweet: recap + CTA (follow, retweet, link in reply)",
2045
+ "No tweet should feel like filler",
2046
+ "Use line breaks within tweets for readability",
2047
+ "Keep transitions minimal — 'Here's the thing:', 'But:', 'The fix:'",
2048
+ ],
2049
+ structure: "1/ Hook → 2-8/ One point per tweet → Final/ Recap + CTA",
2050
+ example: "1/ Most equity conversations between founders and operators go sideways.\n\nHere's a framework that actually works:\n\n2/ Stop arguing time.\n\n\"I've been here 18 months\" doesn't move a founder who's been at it for years.\n\n3/ Argue capital instead.\n\nMoney you invested > Revenue you enabled > Risk you absorbed > Time spent\n\nStart from the top.\n\n4/ Reframe dilution as value creation.\n\n\"What's the company worth WITH what I built vs WITHOUT it?\"\n\nDon't fight their spreadsheet. Build a better one.\n\n5/ Bundle everything.\n\nThe moment you agree to \"close the easy parts first\" — you've lost your leverage.\n\n6/ The frame matters more than the math.\n\nSame contribution, different perception = completely different offer.\n\nYour job isn't to argue percentages. It's to control the frame.",
2051
+ },
2052
+ };
2053
+
2054
+ const spec = platformSpecs[platform];
2055
+ if (!spec) {
2056
+ console.log(`Unknown platform: ${platform}`);
2057
+ console.log(`Available: ${Object.keys(platformSpecs).join(", ")}`);
2058
+ return;
2059
+ }
2060
+
2061
+ // Save adaptation to .variations/<slug>/
2062
+ const varsPath = path.join(cfg.notesDir, ".variations", slug);
2063
+ fs.mkdirSync(varsPath, { recursive: true });
2064
+
2065
+ console.log(`📱 Adapt for ${spec.name}: "${title}"`);
2066
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2067
+ console.log(`Platform: ${spec.name} (${spec.format})`);
2068
+ console.log(`Max chars: ${spec.maxChars}`);
2069
+ console.log(`Source: ${wordCount} words\n`);
2070
+
2071
+ console.log(`Original blog post:`);
2072
+ console.log(body);
2073
+ console.log();
2074
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2075
+ console.log(`Adapt this blog post into a ${spec.name} ${spec.format}.\n`);
2076
+
2077
+ console.log(`📋 PLATFORM RULES for ${spec.name}:`);
2078
+ for (const c of spec.conventions) console.log(` • ${c}`);
2079
+ console.log();
2080
+
2081
+ console.log(`📐 STRUCTURE: ${spec.structure}`);
2082
+ console.log();
2083
+
2084
+ console.log(`📝 EXAMPLE of good ${spec.name} ${spec.format} on similar topic:`);
2085
+ console.log(` ${spec.example.split("\n").join("\n ")}`);
2086
+ console.log();
2087
+
2088
+ if (spec.maxChars <= 280) {
2089
+ console.log(`⚠️ CHARACTER LIMIT: ${spec.maxChars} chars. Count carefully. Every word must earn its place.`);
2090
+ console.log();
2091
+ }
2092
+
2093
+ // Style voice injection
2094
+ if (namedPersona) {
2095
+ console.log(`🎭 VOICE (${namedPersona.name}):`);
2096
+ console.log(` ${namedPersona.voiceDescription}`);
2097
+ if (namedPersona.avoid?.length) console.log(` Avoid: ${namedPersona.avoid.slice(0, 5).join(", ")}`);
2098
+ console.log();
2099
+ } else if (styleProfile?.voiceDescription) {
2100
+ console.log(`🎨 VOICE (your style profile):`);
2101
+ console.log(` ${styleProfile.voiceDescription}`);
2102
+ console.log();
2103
+ }
2104
+
2105
+ console.log(`Output Instructions:`);
2106
+ console.log(`- Distill the core insight — don't try to compress the whole post`);
2107
+ console.log(`- Match the platform's native feel — it should look like it was written for ${spec.name}, not copy-pasted from a blog`);
2108
+ console.log(`- Preserve the author's voice and perspective`);
2109
+ if (platform === "thread") {
2110
+ console.log(`- Output each tweet on its own line, prefixed with the number (1/, 2/, etc.)`);
2111
+ console.log(`- Separate tweets with a blank line`);
2112
+ }
2113
+ console.log();
2114
+ console.log(`After adapting, save the result to: ${varsPath}/${platform}.md`);
2115
+ console.log(`Format: plain text (no markdown), ready to copy-paste into ${spec.name}.`);
2116
+ });
2117
+
2118
+ // ── tweet (post twitter adaptation to X) ─────────────────────
2119
+ notes
2120
+ .command("tweet <slug>")
2121
+ .option("--dry-run", "preview only, don't post")
2122
+ .option("--yes", "skip confirmation")
2123
+ .action(async (slug: string, options: any) => {
2124
+ const cfg = resolveConfig(api);
2125
+ const twitterPath = path.join(cfg.notesDir, ".variations", slug, "twitter.md");
2126
+ if (!fs.existsSync(twitterPath)) {
2127
+ console.log(`No twitter adaptation found for "${slug}".`);
2128
+ console.log(`Run: openclaw notes adapt ${slug} --platform twitter`);
2129
+ return;
2130
+ }
2131
+ const tweetText = fs.readFileSync(twitterPath, "utf8").trim();
2132
+ if (tweetText.length > 280) {
2133
+ console.log(`⚠️ Tweet is ${tweetText.length} chars (max 280). Edit ${twitterPath} to shorten.`);
2134
+ return;
2135
+ }
2136
+
2137
+ console.log(`\n🐦 Tweet preview (${tweetText.length}/280 chars):\n`);
2138
+ console.log(` ${tweetText}\n`);
2139
+
2140
+ if (options.dryRun) {
2141
+ console.log("(dry run — not posting)");
2142
+ return;
2143
+ }
2144
+
2145
+ if (!options.yes) {
2146
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2147
+ const answer = await rl.question("Post to @pressclawai? (y/n) ");
2148
+ rl.close();
2149
+ if (answer.toLowerCase() !== "y") { console.log("Cancelled."); return; }
2150
+ }
2151
+
2152
+ try {
2153
+ const result = await postTweet(tweetText);
2154
+ console.log(`✅ Posted! https://x.com/pressclawai/status/${result.id}`);
2155
+
2156
+ // Save tweet record to feedback file
2157
+ let fb = loadFeedback(cfg.notesDir, slug);
2158
+ if (!fb) { fb = { slug, entries: [], tweets: [], aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 } }; }
2159
+ if (!fb.tweets) fb.tweets = [];
2160
+ fb.tweets.push({ id: result.id, text: result.text, postedAt: new Date().toISOString(), platform: "twitter" });
2161
+ saveFeedback(cfg.notesDir, fb);
2162
+ console.log(`📎 Tweet ID saved to .feedback/${slug}.json`);
2163
+ } catch (err: any) {
2164
+ console.log(`❌ Failed to post: ${err.message}`);
2165
+ }
2166
+ });
2167
+
2168
+ // ── engagement (fetch tweet metrics) ────────────────────────────
2169
+ notes
2170
+ .command("engagement [slug]")
2171
+ .action(async (slug?: string) => {
2172
+ const cfg = resolveConfig(api);
2173
+ const feedbackFiles: FeedbackFile[] = [];
2174
+ if (slug) {
2175
+ const fb = loadFeedback(cfg.notesDir, slug);
2176
+ if (!fb) { console.log(`No feedback file for ${slug}`); return; }
2177
+ feedbackFiles.push(fb);
2178
+ } else {
2179
+ feedbackFiles.push(...loadAllFeedback(cfg.notesDir));
2180
+ }
2181
+
2182
+ const tweetIdMap: Map<string, FeedbackFile> = new Map();
2183
+ for (const fb of feedbackFiles) {
2184
+ if (fb.tweets?.length) {
2185
+ for (const t of fb.tweets) {
2186
+ tweetIdMap.set(t.id, fb);
2187
+ }
2188
+ }
2189
+ }
2190
+
2191
+ if (tweetIdMap.size === 0) {
2192
+ console.log("No tweets found in feedback files.");
2193
+ return;
2194
+ }
2195
+
2196
+ console.log(`Fetching metrics for ${tweetIdMap.size} tweet(s)…`);
2197
+ let results: { id: string; metrics: { views: number; likes: number; shares: number; comments: number } }[];
2198
+ try {
2199
+ results = await fetchTweetMetrics([...tweetIdMap.keys()]);
2200
+ } catch (err: any) {
2201
+ console.log(`❌ Failed to fetch metrics: ${err.message}`);
2202
+ return;
2203
+ }
2204
+
2205
+ const updatedSlugs = new Set<string>();
2206
+ const rows: { slug: string; views: number; likes: number; shares: number; comments: number }[] = [];
2207
+
2208
+ for (const r of results) {
2209
+ const fb = tweetIdMap.get(r.id);
2210
+ if (!fb) continue;
2211
+
2212
+ const score = r.metrics.likes > 100 ? 9 : r.metrics.likes > 50 ? 8 : r.metrics.likes > 10 ? 7 : r.metrics.likes > 5 ? 6 : 5;
2213
+ const existing = fb.entries.find((e) => e.platform === "twitter" && e.note?.includes(r.id));
2214
+ if (existing) {
2215
+ existing.metrics = r.metrics;
2216
+ existing.score = score;
2217
+ existing.date = new Date().toISOString().slice(0, 10);
2218
+ } else {
2219
+ fb.entries.push({
2220
+ date: new Date().toISOString().slice(0, 10),
2221
+ score,
2222
+ metrics: r.metrics,
2223
+ platform: "twitter",
2224
+ note: `Auto-tracked engagement for tweet ${r.id}`,
2225
+ });
2226
+ }
2227
+ fb.aggregate = recalcAggregate(fb.entries);
2228
+ updatedSlugs.add(fb.slug);
2229
+
2230
+ const prev = rows.find((row) => row.slug === fb.slug);
2231
+ if (prev) {
2232
+ prev.views += r.metrics.views;
2233
+ prev.likes += r.metrics.likes;
2234
+ prev.shares += r.metrics.shares;
2235
+ prev.comments += r.metrics.comments;
2236
+ } else {
2237
+ rows.push({ slug: fb.slug, ...r.metrics });
2238
+ }
2239
+ }
2240
+
2241
+ for (const fb of feedbackFiles) {
2242
+ if (updatedSlugs.has(fb.slug)) {
2243
+ saveFeedback(cfg.notesDir, fb);
2244
+ }
2245
+ }
2246
+
2247
+ console.log("\n📊 Engagement Update\n");
2248
+ const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - s.length));
2249
+ console.log(` ${pad("slug", 30)} ${pad("views", 8)} ${pad("likes", 8)} ${pad("RTs", 8)} replies`);
2250
+ for (const row of rows) {
2251
+ console.log(` ${pad(row.slug, 30)} ${pad(String(row.views), 8)} ${pad(String(row.likes), 8)} ${pad(String(row.shares), 8)} ${row.comments}`);
2252
+ }
2253
+ console.log(`\n Updated ${updatedSlugs.size} feedback file(s).`);
2254
+ });
2255
+
2256
+ // ── schedule (scheduled posting) ─────────────────────────────
2257
+ const schedule = notes.command("schedule");
2258
+
2259
+ // `openclaw notes schedule add <slug>` — schedule a post
2260
+ schedule
2261
+ .command("add <slug>")
2262
+ .option("--at <datetime>", "ISO datetime or human-readable time to post")
2263
+ .option("--platform <platform>", "platform to post to", "twitter")
2264
+ .action(async (slug: string, options: any) => {
2265
+ const cfg = resolveConfig(api);
2266
+
2267
+ const platform = options.platform as "twitter" | "linkedin";
2268
+ if (platform !== "twitter" && platform !== "linkedin") {
2269
+ console.log(`❌ Unsupported platform "${platform}". Use: twitter, linkedin`);
2270
+ return;
2271
+ }
2272
+
2273
+ // Validate datetime
2274
+ if (!options.at) {
2275
+ console.log("❌ Missing --at <datetime>. Example: --at '2026-02-08T14:00:00Z'");
2276
+ return;
2277
+ }
2278
+
2279
+ const scheduledAt = new Date(options.at);
2280
+ if (isNaN(scheduledAt.getTime())) {
2281
+ console.log(`❌ Invalid datetime: "${options.at}". Use ISO format, e.g. 2026-02-08T14:00:00Z`);
2282
+ return;
2283
+ }
2284
+
2285
+ if (scheduledAt.getTime() <= Date.now()) {
2286
+ console.log(`❌ Scheduled time must be in the future. Got: ${scheduledAt.toISOString()}`);
2287
+ return;
2288
+ }
2289
+
2290
+ // Validate adaptation exists
2291
+ const platformFile = platform === "twitter" ? "twitter.md" : "linkedin.md";
2292
+ const adaptPath = path.join(cfg.notesDir, ".variations", slug, platformFile);
2293
+ if (!fs.existsSync(adaptPath)) {
2294
+ console.log(`❌ No ${platform} adaptation found for "${slug}".`);
2295
+ console.log(`Run: openclaw notes adapt ${slug} --platform ${platform}`);
2296
+ return;
2297
+ }
2298
+
2299
+ const text = fs.readFileSync(adaptPath, "utf8").trim();
2300
+
2301
+ // Validate length
2302
+ if (platform === "twitter" && text.length > 280) {
2303
+ console.log(`⚠️ Tweet is ${text.length} chars (max 280). Edit ${adaptPath} to shorten.`);
2304
+ return;
2305
+ }
2306
+
2307
+ // Check for duplicate queued entry
2308
+ const queue = loadQueue(cfg.notesDir);
2309
+ const existing = queue.find(
2310
+ (e) => e.slug === slug && e.platform === platform && e.status === "queued"
2311
+ );
2312
+ if (existing) {
2313
+ console.log(`⚠️ "${slug}" is already queued for ${platform} at ${existing.scheduledAt}.`);
2314
+ console.log(`Cancel it first with: openclaw notes schedule cancel ${slug}`);
2315
+ return;
2316
+ }
2317
+
2318
+ const entry: ScheduleEntry = {
2319
+ slug,
2320
+ platform,
2321
+ scheduledAt: scheduledAt.toISOString(),
2322
+ text,
2323
+ status: "queued",
2324
+ createdAt: new Date().toISOString(),
2325
+ };
2326
+
2327
+ queue.push(entry);
2328
+ saveQueue(cfg.notesDir, queue);
2329
+
2330
+ console.log(`\n📅 Scheduled "${slug}" for ${platform}`);
2331
+ console.log(` Time: ${scheduledAt.toISOString()}`);
2332
+ console.log(` Text: ${text.length <= 80 ? text : text.slice(0, 77) + "..."}`);
2333
+ console.log(` Length: ${text.length} chars`);
2334
+ console.log(`\nRun \`openclaw notes schedule process\` at the scheduled time to post.`);
2335
+ });
2336
+
2337
+ // `openclaw notes schedule list` — list all scheduled entries
2338
+ schedule
2339
+ .command("list")
2340
+ .alias("ls")
2341
+ .action(() => {
2342
+ const cfg = resolveConfig(api);
2343
+ const queue = loadQueue(cfg.notesDir);
2344
+
2345
+ if (queue.length === 0) {
2346
+ console.log("📭 No scheduled posts.");
2347
+ return;
2348
+ }
2349
+
2350
+ const statusOrder: Record<string, number> = { queued: 0, posted: 1, failed: 2, cancelled: 3 };
2351
+ const sorted = [...queue].sort(
2352
+ (a, b) => (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4)
2353
+ );
2354
+
2355
+ const statusIcon: Record<string, string> = {
2356
+ queued: "🟡",
2357
+ posted: "🟢",
2358
+ failed: "🔴",
2359
+ cancelled: "⚪",
2360
+ };
2361
+
2362
+ console.log(`\n📅 Scheduled Posts (${queue.length} total)\n`);
2363
+
2364
+ let lastStatus = "";
2365
+ for (const entry of sorted) {
2366
+ if (entry.status !== lastStatus) {
2367
+ if (lastStatus) console.log("");
2368
+ console.log(` ${statusIcon[entry.status] || "⚪"} ${entry.status.toUpperCase()}`);
2369
+ lastStatus = entry.status;
2370
+ }
2371
+ const time = new Date(entry.scheduledAt).toLocaleString();
2372
+ const preview = entry.text.length <= 50 ? entry.text : entry.text.slice(0, 47) + "...";
2373
+ console.log(` ${entry.slug} (${entry.platform}) — ${time}`);
2374
+ console.log(` "${preview}"`);
2375
+ if (entry.tweetId) {
2376
+ console.log(` → https://x.com/pressclawai/status/${entry.tweetId}`);
2377
+ }
2378
+ if (entry.error) {
2379
+ console.log(` ✗ ${entry.error}`);
2380
+ }
2381
+ }
2382
+ console.log("");
2383
+ });
2384
+
2385
+ // `openclaw notes schedule cancel <slug>` — cancel a queued entry
2386
+ schedule
2387
+ .command("cancel <slug>")
2388
+ .option("--platform <platform>", "platform to cancel", "twitter")
2389
+ .action((slug: string, options: any) => {
2390
+ const cfg = resolveConfig(api);
2391
+ const queue = loadQueue(cfg.notesDir);
2392
+ const platform = options.platform || "twitter";
2393
+
2394
+ const idx = queue.findIndex(
2395
+ (e) => e.slug === slug && e.platform === platform && e.status === "queued"
2396
+ );
2397
+
2398
+ if (idx === -1) {
2399
+ console.log(`❌ No queued entry found for "${slug}" on ${platform}.`);
2400
+ return;
2401
+ }
2402
+
2403
+ queue[idx].status = "cancelled";
2404
+ saveQueue(cfg.notesDir, queue);
2405
+
2406
+ console.log(`✅ Cancelled scheduled post for "${slug}" (${platform}).`);
2407
+ console.log(` Was scheduled for: ${queue[idx].scheduledAt}`);
2408
+ });
2409
+
2410
+ // `openclaw notes schedule process` — post all due entries
2411
+ schedule
2412
+ .command("process")
2413
+ .action(async () => {
2414
+ const cfg = resolveConfig(api);
2415
+ const queue = loadQueue(cfg.notesDir);
2416
+ const now = Date.now();
2417
+
2418
+ const due = queue.filter(
2419
+ (e) => e.status === "queued" && new Date(e.scheduledAt).getTime() <= now
2420
+ );
2421
+
2422
+ if (due.length === 0) {
2423
+ // Show next upcoming if any
2424
+ const nextQueued = queue
2425
+ .filter((e) => e.status === "queued")
2426
+ .sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
2427
+ if (nextQueued.length > 0) {
2428
+ const next = nextQueued[0];
2429
+ const diff = new Date(next.scheduledAt).getTime() - now;
2430
+ const mins = Math.ceil(diff / 60000);
2431
+ console.log(`⏳ No posts due yet. Next: "${next.slug}" in ${mins} minute(s) (${next.scheduledAt}).`);
2432
+ } else {
2433
+ console.log("📭 No queued posts to process.");
2434
+ }
2435
+ return;
2436
+ }
2437
+
2438
+ console.log(`\n📤 Processing ${due.length} scheduled post(s)...\n`);
2439
+
2440
+ let posted = 0;
2441
+ let failed = 0;
2442
+
2443
+ for (const entry of due) {
2444
+ process.stdout.write(` ${entry.slug} (${entry.platform})... `);
2445
+
2446
+ if (entry.platform === "twitter") {
2447
+ try {
2448
+ const result = await postTweet(entry.text);
2449
+ entry.status = "posted";
2450
+ entry.tweetId = result.id;
2451
+
2452
+ // Save to feedback file (same pattern as tweet command)
2453
+ let fb = loadFeedback(cfg.notesDir, entry.slug);
2454
+ if (!fb) {
2455
+ fb = {
2456
+ slug: entry.slug,
2457
+ entries: [],
2458
+ tweets: [],
2459
+ aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 },
2460
+ };
2461
+ }
2462
+ if (!fb.tweets) fb.tweets = [];
2463
+ fb.tweets.push({
2464
+ id: result.id,
2465
+ text: result.text,
2466
+ postedAt: new Date().toISOString(),
2467
+ platform: "twitter",
2468
+ });
2469
+ saveFeedback(cfg.notesDir, fb);
2470
+
2471
+ console.log(`✅ Posted! https://x.com/pressclawai/status/${result.id}`);
2472
+ posted++;
2473
+ } catch (err: any) {
2474
+ entry.status = "failed";
2475
+ entry.error = err.message || String(err);
2476
+ console.log(`❌ Failed: ${entry.error}`);
2477
+ failed++;
2478
+ }
2479
+ } else if (entry.platform === "linkedin") {
2480
+ try {
2481
+ const result = await postLinkedIn(entry.text);
2482
+ entry.status = "posted";
2483
+ entry.tweetId = result.id;
2484
+
2485
+ let fb = loadFeedback(cfg.notesDir, entry.slug);
2486
+ if (!fb) {
2487
+ fb = {
2488
+ slug: entry.slug,
2489
+ entries: [],
2490
+ tweets: [],
2491
+ aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 },
2492
+ };
2493
+ }
2494
+ if (!fb.tweets) fb.tweets = [];
2495
+ fb.tweets.push({
2496
+ id: result.id,
2497
+ text: entry.text.slice(0, 200) + (entry.text.length > 200 ? "…" : ""),
2498
+ postedAt: new Date().toISOString(),
2499
+ platform: "linkedin",
2500
+ });
2501
+ saveFeedback(cfg.notesDir, fb);
2502
+
2503
+ console.log(`✅ Posted to LinkedIn! ${result.id}`);
2504
+ posted++;
2505
+ } catch (err: any) {
2506
+ entry.status = "failed";
2507
+ entry.error = err.message || String(err);
2508
+ console.log(`❌ Failed: ${entry.error}`);
2509
+ failed++;
2510
+ }
2511
+ } else {
2512
+ entry.status = "failed";
2513
+ entry.error = `Unsupported platform: ${entry.platform}`;
2514
+ console.log(`❌ ${entry.error}`);
2515
+ failed++;
2516
+ }
2517
+ }
2518
+
2519
+ saveQueue(cfg.notesDir, queue);
2520
+
2521
+ console.log(`\n📊 Results: ${posted} posted, ${failed} failed`);
2522
+ if (posted > 0) {
2523
+ console.log(`📎 Post IDs saved to .feedback/ files.`);
2524
+ }
2525
+ });
2526
+
2527
+ // `openclaw notes schedule` (no subcommand) — default to list
2528
+ schedule.action(() => {
2529
+ schedule.commands.find((c: any) => c.name() === "list")?.parse(["list"], { from: "user" });
2530
+ });
2531
+
2532
+ // ── linkedin (post linkedin adaptation) ──────────────────────
2533
+ notes
2534
+ .command("linkedin <slug>")
2535
+ .option("--dry-run", "preview only, don't post")
2536
+ .option("--yes", "skip confirmation")
2537
+ .action(async (slug: string, options: any) => {
2538
+ const cfg = resolveConfig(api);
2539
+ const linkedinPath = path.join(cfg.notesDir, ".variations", slug, "linkedin.md");
2540
+ if (!fs.existsSync(linkedinPath)) {
2541
+ console.log(`No LinkedIn adaptation found for "${slug}".`);
2542
+ console.log(`Run: openclaw notes adapt ${slug} --platform linkedin`);
2543
+ return;
2544
+ }
2545
+ const postText = fs.readFileSync(linkedinPath, "utf8").trim();
2546
+ if (postText.length > 3000) {
2547
+ console.log(`⚠️ Post is ${postText.length} chars (LinkedIn max ~3000). Edit ${linkedinPath} to shorten.`);
2548
+ return;
2549
+ }
2550
+
2551
+ console.log(`\n💼 LinkedIn post preview (${postText.length}/3000 chars):\n`);
2552
+ console.log(` ${postText.split("\n").join("\n ")}\n`);
2553
+
2554
+ if (options.dryRun) {
2555
+ console.log("(dry run — not posting)");
2556
+ return;
2557
+ }
2558
+
2559
+ if (!options.yes) {
2560
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2561
+ const answer = await rl.question("Post to LinkedIn? (y/n) ");
2562
+ rl.close();
2563
+ if (answer.toLowerCase() !== "y") { console.log("Cancelled."); return; }
2564
+ }
2565
+
2566
+ try {
2567
+ const result = await postLinkedIn(postText);
2568
+ console.log(`✅ Posted to LinkedIn!`);
2569
+ if (result.id) {
2570
+ const numericId = result.id.split(":").pop();
2571
+ console.log(` Post URN: ${result.id}`);
2572
+ console.log(` View: https://www.linkedin.com/feed/update/${result.id}`);
2573
+ }
2574
+
2575
+ // Save post record to feedback file
2576
+ let fb = loadFeedback(cfg.notesDir, slug);
2577
+ if (!fb) {
2578
+ fb = {
2579
+ slug,
2580
+ entries: [],
2581
+ tweets: [],
2582
+ aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 },
2583
+ };
2584
+ }
2585
+ if (!fb.tweets) fb.tweets = [];
2586
+ fb.tweets.push({
2587
+ id: result.id,
2588
+ text: postText.slice(0, 200) + (postText.length > 200 ? "…" : ""),
2589
+ postedAt: new Date().toISOString(),
2590
+ platform: "linkedin",
2591
+ });
2592
+ saveFeedback(cfg.notesDir, fb);
2593
+ console.log(`📎 LinkedIn post ID saved to .feedback/${slug}.json`);
2594
+ } catch (err: any) {
2595
+ console.log(`❌ Failed to post: ${err.message}`);
2596
+ }
2597
+ });
2598
+
2599
+ // ── linkedin-auth (setup LinkedIn OAuth) ─────────────────────
2600
+ notes
2601
+ .command("linkedin-auth")
2602
+ .action(async () => {
2603
+ console.log(`\n🔗 LinkedIn API Setup`);
2604
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2605
+ console.log();
2606
+ console.log(`Step 1: Create a LinkedIn App`);
2607
+ console.log(` → Go to https://www.linkedin.com/developers/apps`);
2608
+ console.log(` → Click "Create app"`);
2609
+ console.log(` → Fill in: App name, LinkedIn Page (or create one), logo`);
2610
+ console.log(` → Accept the terms and create`);
2611
+ console.log();
2612
+ console.log(`Step 2: Configure OAuth`);
2613
+ console.log(` → In your app settings, go to the "Auth" tab`);
2614
+ console.log(` → Note your Client ID and Client Secret`);
2615
+ console.log(` → Under "OAuth 2.0 settings", add a redirect URL:`);
2616
+ console.log(` https://localhost:3000/callback (or any URL you control)`);
2617
+ console.log();
2618
+ console.log(`Step 3: Request the w_member_social product`);
2619
+ console.log(` → Go to the "Products" tab in your LinkedIn app`);
2620
+ console.log(` → Request access to "Share on LinkedIn" (grants w_member_social)`);
2621
+ console.log(` → Wait for approval (usually instant for personal apps)`);
2622
+ console.log();
2623
+ console.log(`Step 4: Get an authorization code`);
2624
+ console.log(` → Visit this URL in your browser (replace YOUR_CLIENT_ID and YOUR_REDIRECT_URI):\n`);
2625
+ console.log(` https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&scope=openid%20profile%20w_member_social\n`);
2626
+ console.log(` → Log in and authorize the app`);
2627
+ console.log(` → You'll be redirected to your redirect URL with ?code=XXXXX in the URL`);
2628
+ console.log(` → Copy the code value`);
2629
+ console.log();
2630
+ console.log(`Step 5: Enter your credentials below to exchange for an access token\n`);
2631
+
2632
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2633
+
2634
+ try {
2635
+ const clientId = await rl.question("LinkedIn Client ID: ");
2636
+ if (!clientId.trim()) { console.log("Cancelled."); rl.close(); return; }
2637
+
2638
+ const clientSecret = await rl.question("LinkedIn Client Secret: ");
2639
+ if (!clientSecret.trim()) { console.log("Cancelled."); rl.close(); return; }
2640
+
2641
+ const redirectUri = await rl.question("Redirect URI (same as in your app settings): ");
2642
+ if (!redirectUri.trim()) { console.log("Cancelled."); rl.close(); return; }
2643
+
2644
+ const code = await rl.question("Authorization code (from the redirect URL): ");
2645
+ if (!code.trim()) { console.log("Cancelled."); rl.close(); return; }
2646
+
2647
+ rl.close();
2648
+
2649
+ console.log(`\n⏳ Exchanging authorization code for access token...`);
2650
+
2651
+ const tokenResult = await exchangeLinkedInAuthCode(
2652
+ clientId.trim(),
2653
+ clientSecret.trim(),
2654
+ code.trim(),
2655
+ redirectUri.trim()
2656
+ );
2657
+
2658
+ console.log(`✅ Access token obtained! (expires in ${Math.round(tokenResult.expires_in / 86400)} days)`);
2659
+ console.log(` Scopes: ${tokenResult.scope}`);
2660
+
2661
+ // Fetch person URN
2662
+ console.log(`\n⏳ Fetching your LinkedIn profile...`);
2663
+ let personUrn: string;
2664
+ try {
2665
+ personUrn = await fetchLinkedInPersonUrn(tokenResult.access_token);
2666
+ console.log(`✅ Person URN: ${personUrn}`);
2667
+ } catch (err: any) {
2668
+ console.log(`⚠️ Could not auto-detect Person URN: ${err.message}`);
2669
+ const manualUrn = await (readline.createInterface({ input: process.stdin, output: process.stdout }))
2670
+ .question("Enter your Person URN manually (urn:li:person:XXXX): ");
2671
+ personUrn = manualUrn.trim();
2672
+ if (!personUrn) {
2673
+ console.log("No Person URN provided. You'll need to set LINKEDIN_PERSON_URN manually.");
2674
+ personUrn = "";
2675
+ }
2676
+ }
2677
+
2678
+ // Save to /etc/openclaw/linkedin.env
2679
+ const envDir = "/etc/openclaw";
2680
+ const envPath = path.join(envDir, "linkedin.env");
2681
+ fs.mkdirSync(envDir, { recursive: true });
2682
+
2683
+ const envContent = [
2684
+ `# LinkedIn OAuth credentials for PressClaw`,
2685
+ `# Generated: ${new Date().toISOString()}`,
2686
+ `# Token expires in ~${Math.round(tokenResult.expires_in / 86400)} days`,
2687
+ `LINKEDIN_ACCESS_TOKEN=${tokenResult.access_token}`,
2688
+ personUrn ? `LINKEDIN_PERSON_URN=${personUrn}` : `# LINKEDIN_PERSON_URN=urn:li:person:YOUR_ID`,
2689
+ `LINKEDIN_CLIENT_ID=${clientId.trim()}`,
2690
+ `LINKEDIN_CLIENT_SECRET=${clientSecret.trim()}`,
2691
+ `LINKEDIN_REDIRECT_URI=${redirectUri.trim()}`,
2692
+ ``,
2693
+ ].join("\n");
2694
+
2695
+ fs.writeFileSync(envPath, envContent, { mode: 0o600 });
2696
+ console.log(`\n💾 Credentials saved to ${envPath}`);
2697
+ console.log();
2698
+ console.log(`To load these in your OpenClaw environment, add to your gateway config:`);
2699
+ console.log(` envFile: ${envPath}`);
2700
+ console.log();
2701
+ console.log(`Or source it manually:`);
2702
+ console.log(` source ${envPath}`);
2703
+ console.log();
2704
+ console.log(`You can now post to LinkedIn:`);
2705
+ console.log(` openclaw notes adapt <slug> --platform linkedin`);
2706
+ console.log(` openclaw notes linkedin <slug>`);
2707
+ } catch (err: any) {
2708
+ rl.close();
2709
+ console.log(`\n❌ Error: ${err.message}`);
2710
+ }
2711
+ });
2712
+
2713
+ // ── personas (subcommand group) ────────────────────────────────
2714
+ const personasCmd = notes.command("personas").description("Manage writing personas");
2715
+
2716
+ // ── personas (list installed) ──────────────────────────────────
2717
+ personasCmd.action(() => {
2718
+ const cfg = resolveConfig(api);
2719
+ const personas = loadInstalledPersonas(cfg.notesDir);
2720
+
2721
+ if (personas.length === 0) {
2722
+ console.log("\n🎭 No personas installed yet.");
2723
+ console.log(" Browse available: openclaw notes personas browse");
2724
+ console.log(" Create your own: openclaw notes personas create");
2725
+ return;
2726
+ }
2727
+
2728
+ console.log(`\n🎭 Installed Personas (${personas.length})`);
2729
+ console.log(`${"━".repeat(72)}`);
2730
+ console.log(` ${"Name".padEnd(20)} ${"Description".padEnd(32)} ${"Tags".padEnd(24)} Ver`);
2731
+ console.log(` ${"─".repeat(20)} ${"─".repeat(32)} ${"─".repeat(24)} ${"─".repeat(4)}`);
2732
+
2733
+ for (const p of personas) {
2734
+ const name = p.displayName.slice(0, 19).padEnd(20);
2735
+ const desc = p.description.slice(0, 31).padEnd(32);
2736
+ const tags = p.tags.slice(0, 3).join(", ").slice(0, 23).padEnd(24);
2737
+ const ver = p.version || "—";
2738
+ console.log(` ${name} ${desc} ${tags} ${ver}`);
2739
+ }
2740
+
2741
+ console.log(`\n Browse more: openclaw notes personas browse`);
2742
+ console.log(` Use with: openclaw notes transform <slug> --voice <persona-name>`);
2743
+ });
2744
+
2745
+ // ── personas browse ────────────────────────────────────────────
2746
+ personasCmd.command("browse").action(async () => {
2747
+ const cfg = resolveConfig(api);
2748
+ const installed = new Set(loadInstalledPersonas(cfg.notesDir).map((p) => p.name));
2749
+
2750
+ console.log("\n🌐 Persona Marketplace");
2751
+ console.log(`${"━".repeat(56)}`);
2752
+ console.log(" Fetching from pressclaw.com...\n");
2753
+
2754
+ try {
2755
+ const index = await fetchJson("https://pressclaw.com/personas/index.json");
2756
+
2757
+ if (!index?.personas || !Array.isArray(index.personas)) {
2758
+ console.log(" ⚠️ Invalid marketplace index format.");
2759
+ return;
2760
+ }
2761
+
2762
+ const available = index.personas.filter((p: any) => !installed.has(p.name));
2763
+
2764
+ if (available.length === 0 && index.personas.length > 0) {
2765
+ console.log(" ✅ You already have all available personas installed!");
2766
+ console.log(` ${index.personas.length} persona(s) in marketplace, all installed locally.`);
2767
+ return;
2768
+ }
2769
+
2770
+ if (available.length === 0) {
2771
+ console.log(" No personas available in the marketplace yet.");
2772
+ return;
2773
+ }
2774
+
2775
+ console.log(` ${available.length} persona(s) available:\n`);
2776
+ for (const p of available) {
2777
+ const tags = p.tags?.length ? ` [${p.tags.join(", ")}]` : "";
2778
+ console.log(` 🎭 ${p.displayName || p.name} (${p.name}) v${p.version || "?"}`);
2779
+ console.log(` ${p.description || "No description"}${tags}`);
2780
+ console.log();
2781
+ }
2782
+
2783
+ if (installed.size > 0) {
2784
+ console.log(` Already installed: ${[...installed].join(", ")}`);
2785
+ console.log();
2786
+ }
2787
+
2788
+ console.log(` Install: openclaw notes personas install <name>`);
2789
+ } catch (err: any) {
2790
+ console.log(" 🌐 Marketplace is not available yet.");
2791
+ console.log(` (${err?.message || "Connection failed"})`);
2792
+ console.log();
2793
+ console.log(" You can still create personas locally:");
2794
+ console.log(" openclaw notes personas create");
2795
+ }
2796
+ });
2797
+
2798
+ // ── personas install <name> ────────────────────────────────────
2799
+ personasCmd.command("install <name>").action(async (name: string) => {
2800
+ const cfg = resolveConfig(api);
2801
+ ensurePersonasDir(cfg.notesDir);
2802
+
2803
+ const existing = loadInstalledPersona(cfg.notesDir, name);
2804
+ if (existing) {
2805
+ console.log(`\n⚠️ Persona "${existing.displayName}" (${name}) is already installed.`);
2806
+ console.log(` Version: ${existing.version}`);
2807
+ return;
2808
+ }
2809
+
2810
+ console.log(`\n📥 Installing persona: ${name}...`);
2811
+
2812
+ try {
2813
+ const data = await fetchJson(`https://pressclaw.com/personas/${name}.json`);
2814
+
2815
+ if (!validatePersona(data)) {
2816
+ console.log(`\n❌ Invalid persona format — missing required fields.`);
2817
+ console.log(` Required: name, displayName, description, version, author, style.tone, style.patterns, examples, tags`);
2818
+ return;
2819
+ }
2820
+
2821
+ const dest = path.join(personasDir(cfg.notesDir), `${name}.json`);
2822
+ fs.writeFileSync(dest, JSON.stringify(data, null, 2) + "\n", "utf8");
2823
+
2824
+ console.log(`\n✅ Installed: ${data.displayName}`);
2825
+ console.log(` ${data.description}`);
2826
+ console.log(` Tone: ${data.style.tone} · ${data.style.patterns.length} patterns · ${data.examples.length} examples`);
2827
+ console.log(` Tags: ${data.tags.join(", ")}`);
2828
+ console.log(`\n Use: openclaw notes transform <slug> --voice ${name}`);
2829
+ } catch (err: any) {
2830
+ console.log(`\n❌ Failed to install persona "${name}".`);
2831
+ console.log(` ${err?.message || "Download failed"}`);
2832
+ console.log(`\n Check available personas: openclaw notes personas browse`);
2833
+ }
2834
+ });
2835
+
2836
+ // ── personas export <name> ─────────────────────────────────────
2837
+ personasCmd.command("export <name>").action((name: string) => {
2838
+ const cfg = resolveConfig(api);
2839
+ const persona = loadInstalledPersona(cfg.notesDir, name);
2840
+
2841
+ if (!persona) {
2842
+ console.log(`\n❌ Persona "${name}" not found locally.`);
2843
+ console.log(` Installed personas: openclaw notes personas`);
2844
+ return;
2845
+ }
2846
+
2847
+ console.log(JSON.stringify(persona, null, 2));
2848
+ });
2849
+
2850
+ // ── personas create ────────────────────────────────────────────
2851
+ personasCmd.command("create").action(async () => {
2852
+ const cfg = resolveConfig(api);
2853
+ ensurePersonasDir(cfg.notesDir);
2854
+
2855
+ if (!process.stdin.isTTY) {
2856
+ console.log("❌ Interactive mode required. Run in a terminal.");
2857
+ return;
2858
+ }
2859
+
2860
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2861
+
2862
+ try {
2863
+ console.log("\n🎭 Create a New Persona");
2864
+ console.log(`${"━".repeat(40)}\n`);
2865
+
2866
+ const rawName = await rl.question("Name (slug, e.g. 'my-voice'): ");
2867
+ const name = slugifyPersonaName(rawName);
2868
+ if (!name) { console.log("❌ Invalid name."); rl.close(); return; }
2869
+
2870
+ // Check if already exists
2871
+ if (loadInstalledPersona(cfg.notesDir, name)) {
2872
+ console.log(`❌ Persona "${name}" already exists. Delete it first or choose another name.`);
2873
+ rl.close();
2874
+ return;
2875
+ }
2876
+
2877
+ const displayName = await rl.question("Display name (e.g. 'Paul Graham'): ");
2878
+ if (!displayName.trim()) { console.log("❌ Display name is required."); rl.close(); return; }
2879
+
2880
+ const description = await rl.question("Description (one line): ");
2881
+ const tone = await rl.question("Tone (e.g. analytical, conversational, provocative): ");
2882
+
2883
+ console.log("\nEnter 3 writing patterns (one per line):");
2884
+ const pattern1 = await rl.question(" Pattern 1: ");
2885
+ const pattern2 = await rl.question(" Pattern 2: ");
2886
+ const pattern3 = await rl.question(" Pattern 3: ");
2887
+ const patterns = [pattern1, pattern2, pattern3].filter((p) => p.trim());
2888
+
2889
+ if (patterns.length === 0) {
2890
+ console.log("❌ At least one writing pattern is required.");
2891
+ rl.close();
2892
+ return;
2893
+ }
2894
+
2895
+ const tagsRaw = await rl.question("Tags (comma-separated, e.g. tech, essays): ");
2896
+ const tags = tagsRaw.split(",").map((t: string) => t.trim()).filter((t: string) => t);
2897
+
2898
+ rl.close();
2899
+
2900
+ const persona: Persona = {
2901
+ name,
2902
+ displayName: displayName.trim(),
2903
+ description: description.trim() || `Custom persona: ${displayName.trim()}`,
2904
+ version: "1.0",
2905
+ author: "local",
2906
+ style: {
2907
+ tone: tone.trim() || "custom",
2908
+ patterns,
2909
+ },
2910
+ examples: [],
2911
+ tags,
2912
+ };
2913
+
2914
+ const dest = path.join(personasDir(cfg.notesDir), `${name}.json`);
2915
+ fs.writeFileSync(dest, JSON.stringify(persona, null, 2) + "\n", "utf8");
2916
+
2917
+ console.log(`\n✅ Created persona: ${persona.displayName}`);
2918
+ console.log(` Saved to: ${dest}`);
2919
+ console.log(` Use: openclaw notes transform <slug> --voice ${name}`);
2920
+ console.log(` Export: openclaw notes personas export ${name}`);
2921
+ } catch (err: any) {
2922
+ rl.close();
2923
+ console.log(`\n❌ Error creating persona: ${err?.message || "unknown"}`);
2924
+ }
2925
+ });
2926
+
2927
+ // ── personas delete <name> ─────────────────────────────────────
2928
+ personasCmd.command("delete <name>").action(async (name: string) => {
2929
+ const cfg = resolveConfig(api);
2930
+ const persona = loadInstalledPersona(cfg.notesDir, name);
2931
+
2932
+ if (!persona) {
2933
+ console.log(`\n❌ Persona "${name}" not found locally.`);
2934
+ console.log(` Installed personas: openclaw notes personas`);
2935
+ return;
2936
+ }
2937
+
2938
+ if (process.stdin.isTTY) {
2939
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2940
+ const answer = await rl.question(`Delete persona "${persona.displayName}" (${name})? (y/N) `);
2941
+ rl.close();
2942
+ if (!(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes")) {
2943
+ console.log("Cancelled.");
2944
+ return;
2945
+ }
2946
+ }
2947
+
2948
+ const file = path.join(personasDir(cfg.notesDir), `${name}.json`);
2949
+ fs.unlinkSync(file);
2950
+ console.log(`\n🗑️ Deleted persona: ${persona.displayName} (${name})`);
2951
+ });
2952
+
2953
+ // ── analyze ───────────────────────────────────────────────────
2954
+ notes.command("analyze <slug>").action((slug: string) => {
2955
+ const cfg = resolveConfig(api);
2956
+ const file = path.join(cfg.notesDir, `${slug}.md`);
2957
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
2958
+ const full = fs.readFileSync(file, "utf8");
2959
+ const { meta, body } = parseFrontMatter(full);
2960
+ const title = meta.title || slug;
2961
+
2962
+ const markers = extractStyleMarkers(body);
2963
+
2964
+ // Write markers to frontmatter
2965
+ const updatedMeta = { ...meta, status: meta.status || "private" };
2966
+ const updated = writeFrontmatterWithMarkers(updatedMeta, body, markers);
2967
+ fs.writeFileSync(file, updated, "utf8");
2968
+
2969
+ console.log(`📊 Style Markers: "${title}" (${slug}.md)`);
2970
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2971
+ console.log(` Sentences: ${markers.sentences}`);
2972
+ console.log(` Paragraphs: ${markers.paragraphs}`);
2973
+ console.log(` Words: ${markers.wordCount}`);
2974
+ console.log(` Avg sentence len: ${markers.avgSentenceLength} words`);
2975
+ console.log(` Avg ¶ sentences: ${markers.avgParagraphSentences}`);
2976
+ console.log(` Perspective: ${markers.perspective}`);
2977
+ console.log(` Emoji usage: ${markers.emojiUsage}`);
2978
+ console.log(` Readability (F-K): ${markers.readabilityScore}/100`);
2979
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
2980
+ console.log(`Markers saved to frontmatter.`);
2981
+ });
2982
+
2983
+ // ── refine ────────────────────────────────────────────────────
2984
+ notes.command("refine <slug>").action((slug: string) => {
2985
+ const cfg = resolveConfig(api);
2986
+ const file = path.join(cfg.notesDir, `${slug}.md`);
2987
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
2988
+ const full = fs.readFileSync(file, "utf8");
2989
+ const { meta, body } = parseFrontMatter(full);
2990
+ const title = meta.title || slug;
2991
+
2992
+ // Extract style markers on refine
2993
+ const markers = extractStyleMarkers(body);
2994
+ const refinedMeta = { ...meta, status: "refined" };
2995
+ const updated = writeFrontmatterWithMarkers(refinedMeta, body, markers);
2996
+ fs.writeFileSync(file, updated, "utf8");
2997
+
2998
+ // Auto-update aggregate profile
2999
+ const profileResult = updateAggregateProfile(cfg.notesDir);
3000
+ if (profileResult.updated) {
3001
+ console.log(`🎨 Style profile auto-updated (${profileResult.noteCount} notes analyzed)`);
3002
+ }
3003
+
3004
+ const topics = readTopics(cfg.notesDir);
3005
+ const linked = topics.find((t) => t.slug === (meta.slug || slug));
3006
+ if (linked && linked.status !== "refined") {
3007
+ linked.status = "refined";
3008
+ writeTopics(cfg.notesDir, topics);
3009
+ }
3010
+
3011
+ console.log(`✨ Refined: "${title}" (${slug}.md)`);
3012
+ console.log(` Status: refined — ready for review\n`);
3013
+ console.log(`Next steps:`);
3014
+ console.log(` Review: openclaw notes preview ${slug}`);
3015
+ console.log(` Test: openclaw notes test ${slug}`);
3016
+ console.log(` Publish: openclaw notes publish ${slug}`);
3017
+ });
3018
+
3019
+ // ── test ──────────────────────────────────────────────────────
3020
+ notes.command("test <slug>").action((slug: string) => {
3021
+ const cfg = resolveConfig(api);
3022
+ const file = path.join(cfg.notesDir, `${slug}.md`);
3023
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
3024
+ const full = fs.readFileSync(file, "utf8");
3025
+ const { meta, body } = parseFrontMatter(full);
3026
+ const title = meta.title || slug;
3027
+ const wordCount = body.split(/\s+/).filter((w: string) => w.length > 0).length;
3028
+
3029
+ // Load custom personas or use defaults
3030
+ const personasConfig = loadPersonas(cfg.notesDir);
3031
+ const defaultPersonas = [
3032
+ { id: "senior-dev", name: "Technical Builder", emoji: "🔧", 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"] },
3033
+ { id: "founder", name: "Startup Founder", emoji: "🚀", 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"] },
3034
+ { id: "learner", name: "Curious Learner", emoji: "📚", 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"] },
3035
+ { id: "brand-strategist", name: "Personal Brand Strategist", emoji: "👤", 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"] },
3036
+ ];
3037
+
3038
+ let personas = defaultPersonas;
3039
+ if (personasConfig?.sets) {
3040
+ const sets = personasConfig.defaultSets || Object.keys(personasConfig.sets);
3041
+ personas = [];
3042
+ for (const setName of sets) {
3043
+ if (personasConfig.sets[setName]?.personas) personas.push(...personasConfig.sets[setName].personas);
3044
+ }
3045
+ }
3046
+
3047
+ console.log(`🧪 Audience Test: "${title}"`);
3048
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3049
+ console.log(`\nContent (${wordCount} words):`);
3050
+ console.log(body);
3051
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3052
+ console.log(`Evaluate this content from ${personas.length} perspectives:\n`);
3053
+
3054
+ personas.forEach((p, i) => {
3055
+ console.log(`${i + 1}. ${p.emoji} **${p.name}**`);
3056
+ console.log(` ${p.description}`);
3057
+ console.log(` Evaluate: ${p.evaluates.join(", ")}`);
3058
+ console.log(` Rate: 👍 Would engage | 🤷 Maybe | 👎 Skip`);
3059
+ console.log(` Score: 1-10`);
3060
+ console.log();
3061
+ });
3062
+
3063
+ console.log(`Then provide:`);
3064
+ console.log(`- Overall confidence score (0-10)`);
3065
+ console.log(`- One specific improvement suggestion\n`);
3066
+
3067
+ // Persistence instructions
3068
+ const varDir = variationsDir(cfg.notesDir, slug);
3069
+ console.log(`📁 After evaluation, save results to:`);
3070
+ console.log(` ${path.join(varDir, "_test-results.json")}`);
3071
+ console.log(`\n Schema:`);
3072
+ console.log(` {`);
3073
+ console.log(` "slug": "${slug}",`);
3074
+ console.log(` "tested": "${new Date().toISOString()}",`);
3075
+ console.log(` "scores": {`);
3076
+ personas.forEach((p) => {
3077
+ console.log(` "${p.id}": { "sentiment": "positive|negative|neutral", "engagement": "would-share|would-comment|would-read|would-skip", "score": N, "feedback": "...", "suggestion": "..." },`);
3078
+ });
3079
+ console.log(` },`);
3080
+ console.log(` "confidence": N,`);
3081
+ console.log(` "topSuggestion": "..."`);
3082
+ console.log(` }`);
3083
+ console.log(`\n Also update the note's frontmatter: confidence: N`);
3084
+ console.log(` Create directory if needed: mkdir -p ${varDir}`);
3085
+ });
3086
+
3087
+ // ── voice ─────────────────────────────────────────────────────
3088
+ notes.command("voice <title>").action((title: string) => {
3089
+ const cfg = resolveConfig(api);
3090
+ fs.mkdirSync(cfg.notesDir, { recursive: true });
3091
+ const base = slugify(title);
3092
+ const slug = uniqueSlug(base, cfg.notesDir);
3093
+ const now = new Date().toISOString().split("T")[0];
3094
+
3095
+ const topics = readTopics(cfg.notesDir);
3096
+ const topic: Topic = { id: generateId(), title, source: "voice", added: now, status: "drafted", slug, tags: undefined };
3097
+ topics.push(topic);
3098
+ writeTopics(cfg.notesDir, topics);
3099
+
3100
+ const content = newNoteFrontmatter({ title, slug, topicId: topic.id, inputType: "voice" }) + "\n";
3101
+ const filename = path.join(cfg.notesDir, `${slug}.md`);
3102
+ fs.writeFileSync(filename, content, "utf8");
3103
+
3104
+ console.log(`🎤 Voice note created`);
3105
+ console.log(` Topic: ${topic.id} — "${title}"`);
3106
+ console.log(` File: ${filename}\n`);
3107
+ console.log(`Write the transcribed content to this file, then run:`);
3108
+ console.log(` openclaw notes refine ${slug}`);
3109
+ });
3110
+
3111
+ // ── preview ────────────────────────────────────────────────────
3112
+ notes.command("preview <slug>").action((slug: string) => {
3113
+ const cfg = resolveConfig(api);
3114
+ const file = path.join(cfg.notesDir, `${slug}.md`);
3115
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
3116
+ const full = fs.readFileSync(file, "utf8");
3117
+ const { meta, body } = parseFrontMatter(full);
3118
+ const title = meta.title || slug;
3119
+ const tags = meta.tags || "[]";
3120
+ const status = meta.status || "private";
3121
+ const excerpt = excerptFrom(body);
3122
+ const wordCount = body.split(/\s+/).filter((w: string) => w.length > 0).length;
3123
+
3124
+ console.log(`📖 Preview: ${title}`);
3125
+ console.log(`${"─".repeat(60)}`);
3126
+ console.log(`Status: ${status}`);
3127
+ console.log(`Slug: ${meta.slug || slug}`);
3128
+ console.log(`Tags: ${tags}`);
3129
+ console.log(`Words: ${wordCount}`);
3130
+ if (meta.tone) console.log(`Tone: ${meta.tone}`);
3131
+ if (meta.structure) console.log(`Structure: ${meta.structure}`);
3132
+ if (meta.confidence) console.log(`Confidence: ${meta.confidence}/10`);
3133
+ console.log(`Excerpt: ${excerpt}`);
3134
+ console.log(`${"─".repeat(60)}`);
3135
+ console.log();
3136
+ console.log(body);
3137
+ console.log();
3138
+ console.log(`${"─".repeat(60)}`);
3139
+ if (status === "public") console.log(`This note is already published.`);
3140
+ else if (status === "refined") {
3141
+ console.log(`This note is refined and ready for publishing.`);
3142
+ console.log(`To publish: openclaw notes publish ${slug} --yes --reason "your reason"`);
3143
+ } else {
3144
+ console.log(`To refine: openclaw notes transform ${slug}`);
3145
+ console.log(`To publish: openclaw notes publish ${slug} --yes --reason "your reason"`);
3146
+ }
3147
+ });
3148
+
3149
+ // ── style ─────────────────────────────────────────────────────
3150
+ notes
3151
+ .command("style")
3152
+ .option("--from <slugs>", "comma-separated slugs to analyze (default: all refined/published)")
3153
+ .action((options: any) => {
3154
+ const cfg = resolveConfig(api);
3155
+ const files = listNotes(cfg.notesDir);
3156
+ const profilePath = path.join(cfg.notesDir, ".style-profile.json");
3157
+
3158
+ // Gather notes to analyze
3159
+ let notesToAnalyze: { slug: string; title: string; body: string }[] = [];
3160
+ if (options.from) {
3161
+ const slugs = options.from.split(",").map((s: string) => s.trim());
3162
+ for (const s of slugs) {
3163
+ const f = path.join(cfg.notesDir, `${s}.md`);
3164
+ if (fs.existsSync(f)) {
3165
+ const { meta, body } = parseFrontMatter(fs.readFileSync(f, "utf8"));
3166
+ notesToAnalyze.push({ slug: s, title: meta.title || s, body });
3167
+ }
3168
+ }
3169
+ } else {
3170
+ for (const f of files) {
3171
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
3172
+ const { meta, body } = parseFrontMatter(full);
3173
+ if (meta.status === "refined" || meta.status === "public") {
3174
+ notesToAnalyze.push({ slug: f.replace(/\.md$/, ""), title: meta.title || f, body });
3175
+ }
3176
+ }
3177
+ }
3178
+
3179
+ if (notesToAnalyze.length === 0) {
3180
+ console.log(`No refined or published notes found to analyze.`);
3181
+ console.log(`Refine some notes first, or use --from to specify slugs.`);
3182
+ return;
3183
+ }
3184
+
3185
+ console.log(`🎨 Style Profile Analysis`);
3186
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3187
+ console.log(`Analyzing ${notesToAnalyze.length} notes:\n`);
3188
+ for (const n of notesToAnalyze) console.log(` - ${n.title} (${n.slug})`);
3189
+
3190
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3191
+ console.log(`Analyze the writing style across these ${notesToAnalyze.length} notes and extract a style profile.\n`);
3192
+ console.log(`Sample content from each note:\n`);
3193
+ for (const n of notesToAnalyze) {
3194
+ const preview = n.body.slice(0, 500);
3195
+ console.log(`--- "${n.title}" ---`);
3196
+ console.log(preview);
3197
+ if (n.body.length > 500) console.log(`... (${n.body.length - 500} more chars)`);
3198
+ console.log();
3199
+ }
3200
+
3201
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3202
+ console.log(`Extract and save a style profile to: ${profilePath}\n`);
3203
+ console.log(`Schema:`);
3204
+ console.log(`{`);
3205
+ console.log(` "updated": "${new Date().toISOString()}",`);
3206
+ console.log(` "analyzedNotes": [${notesToAnalyze.map((n) => `"${n.slug}"`).join(", ")}],`);
3207
+ console.log(` "markers": {`);
3208
+ console.log(` "avgSentenceLength": "short|medium|long",`);
3209
+ console.log(` "paragraphLength": "short|medium|long",`);
3210
+ console.log(` "emojiUsage": "none|rare|frequent",`);
3211
+ console.log(` "toneDefault": "authentic|professional|casual|humorous",`);
3212
+ console.log(` "vocabulary": "description of vocabulary level",`);
3213
+ console.log(` "perspective": "first-person|third-person|mixed",`);
3214
+ console.log(` "openingStyle": "description of how posts typically open",`);
3215
+ console.log(` "closingStyle": "description of how posts typically close"`);
3216
+ console.log(` },`);
3217
+ console.log(` "voiceDescription": "2-3 sentence natural language description of the writing voice",`);
3218
+ console.log(` "avoid": ["list", "of", "anti-patterns"],`);
3219
+ console.log(` "examples": {`);
3220
+ console.log(` "strongOpeners": ["example opener sentences from the analyzed notes"],`);
3221
+ console.log(` "strongClosers": ["example closer sentences from the analyzed notes"]`);
3222
+ console.log(` }`);
3223
+ console.log(`}`);
3224
+ });
3225
+
3226
+ // ── vary ──────────────────────────────────────────────────────
3227
+ notes
3228
+ .command("vary <slug>")
3229
+ .option("--tones <tones>", "comma-separated tones (default: authentic,professional,casual,humorous)")
3230
+ .option("--structures <structures>", "comma-separated structures (default: auto-detect best fit)")
3231
+ .action((slug: string, options: any) => {
3232
+ const cfg = resolveConfig(api);
3233
+ const file = path.join(cfg.notesDir, `${slug}.md`);
3234
+ if (!fs.existsSync(file)) { console.log(`Note not found: ${slug}.md`); return; }
3235
+ const full = fs.readFileSync(file, "utf8");
3236
+ const { meta, body } = parseFrontMatter(full);
3237
+ const title = meta.title || slug;
3238
+ const wordCount = body.split(/\s+/).filter((w: string) => w.length > 0).length;
3239
+ const styleProfile = loadStyleProfile(cfg.notesDir);
3240
+ const templates = loadStructureTemplates(pluginDir);
3241
+ const tones = options.tones ? options.tones.split(",").map((s: string) => s.trim()) : ["authentic", "professional", "casual", "humorous"];
3242
+ const structures = options.structures ? options.structures.split(",").map((s: string) => s.trim()) : [meta.structure || "structured"];
3243
+ const varDir = variationsDir(cfg.notesDir, slug);
3244
+
3245
+ console.log(`🔀 Generate Variations: "${title}"`);
3246
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3247
+ console.log(`Tones: ${tones.join(", ")}`);
3248
+ console.log(`Structures: ${structures.join(", ")}`);
3249
+ console.log(`Variations to generate: ${tones.length * structures.length}`);
3250
+ if (styleProfile) console.log(`Style profile loaded (${styleProfile.analyzedNotes.length} notes analyzed)`);
3251
+ console.log();
3252
+
3253
+ console.log(`Source (${wordCount} words):`);
3254
+ console.log(body);
3255
+ console.log();
3256
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
3257
+
3258
+ // Style injection
3259
+ if (styleProfile) {
3260
+ console.log(`\n🎨 STYLE PROFILE — Maintain this voice across ALL variations:`);
3261
+ console.log(` ${styleProfile.voiceDescription}`);
3262
+ if (styleProfile.avoid?.length) console.log(` Avoid: ${styleProfile.avoid.join(", ")}`);
3263
+ console.log();
3264
+ }
3265
+
3266
+ // Structure templates
3267
+ if (templates.length > 0) {
3268
+ console.log(`📋 STRUCTURE TEMPLATES:`);
3269
+ for (const s of structures) {
3270
+ const tpl = templates.find((t) => t.name === s);
3271
+ if (tpl) console.log(` ${tpl.name}: ${tpl.instructions}`);
3272
+ }
3273
+ console.log();
3274
+ }
3275
+
3276
+ console.log(`Generate ${tones.length * structures.length} variations of this post.\n`);
3277
+ console.log(`For each variation:`);
3278
+ console.log(`1. Apply the specified tone + structure combination`);
3279
+ console.log(`2. Maintain the core insight and key points`);
3280
+ console.log(`3. Keep it atomic: one clear idea per post`);
3281
+ console.log(`4. Match the target word count for the structure`);
3282
+ console.log();
3283
+
3284
+ const variationList: string[] = [];
3285
+ for (const s of structures) {
3286
+ for (const t of tones) {
3287
+ const varFile = `${t}-${s}.md`;
3288
+ variationList.push(varFile);
3289
+ console.log(`📄 ${varFile} — tone: ${t}, structure: ${s}`);
3290
+ }
3291
+ }
3292
+
3293
+ console.log(`\n📁 Save all variation files to: ${varDir}/`);
3294
+ console.log(` Create directory: mkdir -p ${varDir}`);
3295
+ console.log(`\n Each variation is a standalone markdown file (no frontmatter needed).`);
3296
+ console.log(`\n Also create ${varDir}/_manifest.json:`);
3297
+ console.log(` {`);
3298
+ console.log(` "source": "${slug}.md",`);
3299
+ console.log(` "generated": "${new Date().toISOString()}",`);
3300
+ console.log(` "variations": [`);
3301
+ for (const v of variationList) {
3302
+ const parts = v.replace(".md", "").split("-");
3303
+ const t = parts[0];
3304
+ const s = parts.slice(1).join("-");
3305
+ console.log(` { "file": "${v}", "tone": "${t}", "structure": "${s}", "wordCount": N, "tested": false, "confidence": null, "selected": false },`);
3306
+ }
3307
+ console.log(` ],`);
3308
+ console.log(` "selectedVariation": null`);
3309
+ console.log(` }`);
3310
+ });
3311
+
3312
+ // ── variations ───────────────────────────────────────────────
3313
+ notes.command("variations <slug>").action((slug: string) => {
3314
+ const cfg = resolveConfig(api);
3315
+ const manifest = loadManifest(cfg.notesDir, slug);
3316
+ if (!manifest) {
3317
+ console.log(`No variations found for ${slug}. Run: openclaw notes vary ${slug}`);
3318
+ return;
3319
+ }
3320
+
3321
+ const testResults = loadTestResults(cfg.notesDir, slug);
3322
+
3323
+ console.log(`🔀 Variations: "${slug}" (source: ${manifest.source})`);
3324
+ console.log(` Generated: ${manifest.generated}`);
3325
+ if (manifest.selectedVariation) console.log(` Selected: ${manifest.selectedVariation}`);
3326
+ console.log();
3327
+
3328
+ for (const v of manifest.variations) {
3329
+ const selected = v.selected ? " ✅ SELECTED" : "";
3330
+ const tested = v.tested ? ` (confidence: ${v.confidence}/10)` : "";
3331
+ const varFile = path.join(variationsDir(cfg.notesDir, slug), v.file);
3332
+ const exists = fs.existsSync(varFile);
3333
+ console.log(` ${exists ? "📄" : "❌"} ${v.file.padEnd(30)} tone: ${v.tone.padEnd(14)} structure: ${v.structure.padEnd(12)} ${v.wordCount}w${tested}${selected}`);
3334
+ }
3335
+
3336
+ if (testResults) {
3337
+ console.log(`\n Test results: confidence ${testResults.confidence}/10`);
3338
+ if (testResults.topSuggestion) console.log(` Top suggestion: ${testResults.topSuggestion}`);
3339
+ }
3340
+
3341
+ console.log(`\nTo pick a variation: openclaw notes pick ${slug} <tone-structure>`);
3342
+ console.log(`To test: openclaw notes test ${slug}`);
3343
+ });
3344
+
3345
+ // ── pick ─────────────────────────────────────────────────────
3346
+ notes.command("pick <slug> <variation>").action((slug: string, variation: string) => {
3347
+ const cfg = resolveConfig(api);
3348
+ const manifest = loadManifest(cfg.notesDir, slug);
3349
+ if (!manifest) { console.log(`No variations found for ${slug}.`); return; }
3350
+
3351
+ // Find the variation (match by filename or tone-structure)
3352
+ const varName = variation.endsWith(".md") ? variation : `${variation}.md`;
3353
+ const entry = manifest.variations.find((v) => v.file === varName || v.file === `${variation}.md`);
3354
+ if (!entry) {
3355
+ console.log(`Variation "${variation}" not found. Available:`);
3356
+ for (const v of manifest.variations) console.log(` ${v.file}`);
3357
+ return;
3358
+ }
3359
+
3360
+ const varFile = path.join(variationsDir(cfg.notesDir, slug), entry.file);
3361
+ if (!fs.existsSync(varFile)) { console.log(`Variation file not found: ${varFile}`); return; }
3362
+ const varContent = fs.readFileSync(varFile, "utf8");
3363
+
3364
+ // Read current note and replace body
3365
+ const noteFile = path.join(cfg.notesDir, `${slug}.md`);
3366
+ const full = fs.readFileSync(noteFile, "utf8");
3367
+ const { meta } = parseFrontMatter(full);
3368
+
3369
+ // Rebuild note with variation content and updated frontmatter
3370
+ const title = meta.title || slug;
3371
+ const tags = meta.tags || "[]";
3372
+ const updated = `---\ntitle: "${title}"\nslug: "${meta.slug || slug}"\nstatus: ${meta.status || "private"}\npublished_at: ${meta.published_at || "null"}\n${meta.topic_id ? `topic_id: "${meta.topic_id}"\n` : ""}tone: ${entry.tone}\nstructure: ${entry.structure}\nconfidence: ${meta.confidence || "null"}\nvariation_of: "${entry.file}"\ntags: ${tags}\n---\n\n${varContent}\n`;
3373
+ fs.writeFileSync(noteFile, updated, "utf8");
3374
+
3375
+ // Update manifest
3376
+ for (const v of manifest.variations) v.selected = false;
3377
+ entry.selected = true;
3378
+ manifest.selectedVariation = entry.file;
3379
+ const manifestPath = path.join(variationsDir(cfg.notesDir, slug), "_manifest.json");
3380
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
3381
+
3382
+ console.log(`✅ Picked variation: ${entry.file}`);
3383
+ console.log(` Tone: ${entry.tone} | Structure: ${entry.structure}`);
3384
+ console.log(` Content copied to ${slug}.md`);
3385
+ console.log(`\nNext: openclaw notes refine ${slug}`);
3386
+ });
3387
+
3388
+ // ── feedback ─────────────────────────────────────────────────
3389
+ notes
3390
+ .command("feedback <slug>")
3391
+ .description("Record performance feedback for a note")
3392
+ .option("--score <n>", "overall quality rating (1-10)", parseInt)
3393
+ .option("--views <n>", "view count", parseInt)
3394
+ .option("--likes <n>", "likes/hearts", parseInt)
3395
+ .option("--shares <n>", "shares/reposts", parseInt)
3396
+ .option("--comments <n>", "comment count", parseInt)
3397
+ .option("--platform <name>", "platform name (blog, linkedin, twitter)")
3398
+ .option("--note <text>", "free-text note about what worked/didn't")
3399
+ .action((slug: string, options: any) => {
3400
+ const cfg = resolveConfig(api);
3401
+
3402
+ // Verify the note exists
3403
+ const noteFile = path.join(cfg.notesDir, `${slug}.md`);
3404
+ if (!fs.existsSync(noteFile)) {
3405
+ console.log(`❌ Note not found: ${slug}.md`);
3406
+ return;
3407
+ }
3408
+
3409
+ // Validate score
3410
+ if (options.score !== undefined && (options.score < 1 || options.score > 10 || isNaN(options.score))) {
3411
+ console.log("❌ Score must be between 1 and 10");
3412
+ return;
3413
+ }
3414
+
3415
+ if (!options.score && !options.views && !options.likes && !options.shares && !options.comments && !options.note) {
3416
+ console.log("❌ Provide at least --score, a metric (--views/--likes/--shares/--comments), or --note");
3417
+ return;
3418
+ }
3419
+
3420
+ // Build entry
3421
+ const entry: FeedbackEntry = {
3422
+ date: new Date().toISOString(),
3423
+ score: options.score || 0,
3424
+ metrics: {
3425
+ ...(options.views !== undefined ? { views: options.views } : {}),
3426
+ ...(options.likes !== undefined ? { likes: options.likes } : {}),
3427
+ ...(options.shares !== undefined ? { shares: options.shares } : {}),
3428
+ ...(options.comments !== undefined ? { comments: options.comments } : {}),
3429
+ },
3430
+ ...(options.platform ? { platform: options.platform } : {}),
3431
+ ...(options.note ? { note: options.note } : {}),
3432
+ };
3433
+
3434
+ // Load existing or create new
3435
+ let fb = loadFeedback(cfg.notesDir, slug);
3436
+ if (!fb) {
3437
+ fb = { slug, entries: [], aggregate: { avgScore: 0, totalViews: 0, totalLikes: 0, totalShares: 0, totalComments: 0, entries: 0 } };
3438
+ }
3439
+
3440
+ fb.entries.push(entry);
3441
+ fb.aggregate = recalcAggregate(fb.entries);
3442
+ saveFeedback(cfg.notesDir, fb);
3443
+
3444
+ // Update performance weights in style profile
3445
+ updatePerformanceWeights(cfg.notesDir);
3446
+
3447
+ console.log(`\n✅ Feedback recorded for "${slug}"`);
3448
+ console.log(` Score: ${entry.score || "—"} | Platform: ${entry.platform || "—"}`);
3449
+ if (Object.keys(entry.metrics).length > 0) {
3450
+ const parts: string[] = [];
3451
+ if (entry.metrics.views !== undefined) parts.push(`${entry.metrics.views} views`);
3452
+ if (entry.metrics.likes !== undefined) parts.push(`${entry.metrics.likes} likes`);
3453
+ if (entry.metrics.shares !== undefined) parts.push(`${entry.metrics.shares} shares`);
3454
+ if (entry.metrics.comments !== undefined) parts.push(`${entry.metrics.comments} comments`);
3455
+ console.log(` Metrics: ${parts.join(", ")}`);
3456
+ }
3457
+ if (entry.note) console.log(` Note: ${entry.note}`);
3458
+ console.log(`\n Aggregate (${fb.aggregate.entries} entries): avg score ${fb.aggregate.avgScore} · ${fb.aggregate.totalViews} views · ${fb.aggregate.totalLikes} likes · ${fb.aggregate.totalShares} shares`);
3459
+ });
3460
+
3461
+ // ── insights ─────────────────────────────────────────────────
3462
+ notes
3463
+ .command("insights")
3464
+ .description("Correlate performance feedback with style markers")
3465
+ .action(() => {
3466
+ const cfg = resolveConfig(api);
3467
+ const allFeedback = loadAllFeedback(cfg.notesDir);
3468
+
3469
+ if (allFeedback.length === 0) {
3470
+ console.log("No feedback data yet. Record feedback with: openclaw notes feedback <slug> --score 8 --views 100");
3471
+ return;
3472
+ }
3473
+
3474
+ // Gather enriched data
3475
+ interface NoteInsight {
3476
+ slug: string;
3477
+ score: number;
3478
+ totalViews: number;
3479
+ totalLikes: number;
3480
+ totalShares: number;
3481
+ totalComments: number;
3482
+ entries: number;
3483
+ tone: string | null;
3484
+ structure: string | null;
3485
+ markers: Record<string, any> | null;
3486
+ platforms: string[];
3487
+ }
3488
+
3489
+ const notes: NoteInsight[] = [];
3490
+ for (const fb of allFeedback) {
3491
+ if (fb.aggregate.entries === 0) continue;
3492
+ const noteFile = path.join(cfg.notesDir, `${fb.slug}.md`);
3493
+ if (!fs.existsSync(noteFile)) continue;
3494
+ const { meta, body } = parseFrontMatter(fs.readFileSync(noteFile, "utf8"));
3495
+ let markers: Record<string, any> | null = null;
3496
+ if (meta.style_markers) {
3497
+ try { markers = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers; } catch {}
3498
+ }
3499
+ if (!markers) markers = extractStyleMarkers(body);
3500
+ const platforms = [...new Set(fb.entries.filter((e) => e.platform).map((e) => e.platform!))];
3501
+ notes.push({
3502
+ slug: fb.slug,
3503
+ score: fb.aggregate.avgScore,
3504
+ totalViews: fb.aggregate.totalViews,
3505
+ totalLikes: fb.aggregate.totalLikes,
3506
+ totalShares: fb.aggregate.totalShares,
3507
+ totalComments: fb.aggregate.totalComments,
3508
+ entries: fb.aggregate.entries,
3509
+ tone: meta.tone || null,
3510
+ structure: meta.structure || null,
3511
+ markers,
3512
+ platforms,
3513
+ });
3514
+ }
3515
+
3516
+ if (notes.length === 0) {
3517
+ console.log("No matched feedback + notes found.");
3518
+ return;
3519
+ }
3520
+
3521
+ console.log(`\n📊 Performance Insights (${notes.length} notes with feedback)\n`);
3522
+
3523
+ // ── Top performers ──
3524
+ const sorted = [...notes].sort((a, b) => b.score - a.score);
3525
+ console.log("🏆 Top Performers:");
3526
+ for (const n of sorted.slice(0, 5)) {
3527
+ console.log(` ${n.score}/10 ${n.slug} (${n.totalViews} views, ${n.totalLikes} likes, ${n.totalShares} shares)`);
3528
+ }
3529
+ console.log();
3530
+
3531
+ // ── Sentence length correlation ──
3532
+ const withSentLen = notes.filter((n) => n.markers?.avgSentenceLength);
3533
+ if (withSentLen.length >= 2) {
3534
+ const median = [...withSentLen].sort((a, b) => a.score - b.score);
3535
+ const topHalf = median.slice(Math.floor(median.length / 2));
3536
+ const bottomHalf = median.slice(0, Math.floor(median.length / 2));
3537
+ if (topHalf.length > 0 && bottomHalf.length > 0) {
3538
+ const topAvgSL = Math.round(topHalf.reduce((s, n) => s + n.markers!.avgSentenceLength, 0) / topHalf.length * 10) / 10;
3539
+ const botAvgSL = Math.round(bottomHalf.reduce((s, n) => s + n.markers!.avgSentenceLength, 0) / bottomHalf.length * 10) / 10;
3540
+ console.log(`📏 Sentence Length:`);
3541
+ console.log(` Your highest-rated posts average ${topAvgSL} words/sentence (vs ${botAvgSL} for lowest)`);
3542
+ console.log();
3543
+ }
3544
+ }
3545
+
3546
+ // ── Structure correlation ──
3547
+ const structGroups: Record<string, { total: number; count: number }> = {};
3548
+ for (const n of notes) {
3549
+ if (!n.structure) continue;
3550
+ if (!structGroups[n.structure]) structGroups[n.structure] = { total: 0, count: 0 };
3551
+ structGroups[n.structure].total += n.score;
3552
+ structGroups[n.structure].count += 1;
3553
+ }
3554
+ const structEntries = Object.entries(structGroups).map(([k, v]) => ({ structure: k, avg: Math.round((v.total / v.count) * 10) / 10, count: v.count }));
3555
+ if (structEntries.length >= 2) {
3556
+ structEntries.sort((a, b) => b.avg - a.avg);
3557
+ const best = structEntries[0];
3558
+ const worst = structEntries[structEntries.length - 1];
3559
+ const delta = Math.round((best.avg - worst.avg) * 10) / 10;
3560
+ console.log(`🏗️ Structure:`);
3561
+ for (const s of structEntries) {
3562
+ console.log(` "${s.structure}" avg score ${s.avg} (${s.count} notes)`);
3563
+ }
3564
+ if (delta > 0) console.log(` → Posts with '${best.structure}' structure score ${delta} points higher than '${worst.structure}'`);
3565
+ console.log();
3566
+ }
3567
+
3568
+ // ── Tone correlation ──
3569
+ const toneGroups: Record<string, { total: number; count: number }> = {};
3570
+ for (const n of notes) {
3571
+ if (!n.tone) continue;
3572
+ if (!toneGroups[n.tone]) toneGroups[n.tone] = { total: 0, count: 0 };
3573
+ toneGroups[n.tone].total += n.score;
3574
+ toneGroups[n.tone].count += 1;
3575
+ }
3576
+ const toneEntries = Object.entries(toneGroups).map(([k, v]) => ({ tone: k, avg: Math.round((v.total / v.count) * 10) / 10, count: v.count }));
3577
+ if (toneEntries.length >= 2) {
3578
+ toneEntries.sort((a, b) => b.avg - a.avg);
3579
+ console.log(`🎭 Tone:`);
3580
+ for (const t of toneEntries) {
3581
+ console.log(` "${t.tone}" avg score ${t.avg} (${t.count} notes)`);
3582
+ }
3583
+ console.log();
3584
+ } else if (toneEntries.length === 1) {
3585
+ console.log(`🎭 Tone: all feedback notes use "${toneEntries[0].tone}" (avg ${toneEntries[0].avg})`);
3586
+ console.log();
3587
+ }
3588
+
3589
+ // ── Platform comparison ──
3590
+ const platformMetrics: Record<string, { views: number; likes: number; shares: number; comments: number; count: number }> = {};
3591
+ for (const fb of allFeedback) {
3592
+ for (const e of fb.entries) {
3593
+ if (!e.platform) continue;
3594
+ if (!platformMetrics[e.platform]) platformMetrics[e.platform] = { views: 0, likes: 0, shares: 0, comments: 0, count: 0 };
3595
+ const pm = platformMetrics[e.platform];
3596
+ pm.views += e.metrics.views || 0;
3597
+ pm.likes += e.metrics.likes || 0;
3598
+ pm.shares += e.metrics.shares || 0;
3599
+ pm.comments += e.metrics.comments || 0;
3600
+ pm.count += 1;
3601
+ }
3602
+ }
3603
+ const platEntries = Object.entries(platformMetrics);
3604
+ if (platEntries.length >= 2) {
3605
+ console.log(`🌐 Platform Comparison:`);
3606
+ for (const [platform, pm] of platEntries) {
3607
+ console.log(` ${platform}: ${pm.views} views, ${pm.likes} likes, ${pm.shares} shares, ${pm.comments} comments (${pm.count} entries)`);
3608
+ }
3609
+ // Cross-platform share comparison
3610
+ const platShareAvgs = platEntries
3611
+ .filter(([, pm]) => pm.count > 0)
3612
+ .map(([platform, pm]) => ({ platform, avgShares: Math.round(pm.shares / pm.count * 10) / 10 }))
3613
+ .sort((a, b) => b.avgShares - a.avgShares);
3614
+ if (platShareAvgs.length >= 2 && platShareAvgs[platShareAvgs.length - 1].avgShares > 0) {
3615
+ const ratio = Math.round(platShareAvgs[0].avgShares / platShareAvgs[platShareAvgs.length - 1].avgShares * 10) / 10;
3616
+ console.log(` → ${platShareAvgs[0].platform} gets ${ratio}x more shares than ${platShareAvgs[platShareAvgs.length - 1].platform}`);
3617
+ }
3618
+ console.log();
3619
+ }
3620
+
3621
+ // ── Word count correlation ──
3622
+ const withWordCount = notes.filter((n) => n.markers?.wordCount);
3623
+ if (withWordCount.length >= 2) {
3624
+ const sortedByScore = [...withWordCount].sort((a, b) => a.score - b.score);
3625
+ const topHalf = sortedByScore.slice(Math.floor(sortedByScore.length / 2));
3626
+ const bottomHalf = sortedByScore.slice(0, Math.floor(sortedByScore.length / 2));
3627
+ if (topHalf.length > 0 && bottomHalf.length > 0) {
3628
+ const topAvgWC = Math.round(topHalf.reduce((s, n) => s + n.markers!.wordCount, 0) / topHalf.length);
3629
+ const botAvgWC = Math.round(bottomHalf.reduce((s, n) => s + n.markers!.wordCount, 0) / bottomHalf.length);
3630
+ console.log(`📝 Word Count:`);
3631
+ console.log(` Top-scoring posts average ${topAvgWC} words (vs ${botAvgWC} for lowest-scoring)`);
3632
+ console.log();
3633
+ }
3634
+ }
3635
+
3636
+ // ── Readability correlation ──
3637
+ const withReadability = notes.filter((n) => n.markers?.readabilityScore);
3638
+ if (withReadability.length >= 2) {
3639
+ const sortedByScore = [...withReadability].sort((a, b) => a.score - b.score);
3640
+ const topHalf = sortedByScore.slice(Math.floor(sortedByScore.length / 2));
3641
+ const bottomHalf = sortedByScore.slice(0, Math.floor(sortedByScore.length / 2));
3642
+ if (topHalf.length > 0 && bottomHalf.length > 0) {
3643
+ const topAvgRd = Math.round(topHalf.reduce((s, n) => s + n.markers!.readabilityScore, 0) / topHalf.length);
3644
+ const botAvgRd = Math.round(bottomHalf.reduce((s, n) => s + n.markers!.readabilityScore, 0) / bottomHalf.length);
3645
+ console.log(`📖 Readability:`);
3646
+ console.log(` Top-scoring posts: ${topAvgRd}/100 FK score vs ${botAvgRd}/100 for lowest-scoring`);
3647
+ console.log();
3648
+ }
3649
+ }
3650
+
3651
+ console.log(`💡 Tip: Record more feedback to improve correlations. Run: openclaw notes feedback <slug> --score 8 --platform linkedin`);
3652
+ });
3653
+
3654
+ // ── subscribe ─────────────────────────────────────────────────
3655
+ notes
3656
+ .command("subscribe [url]")
3657
+ .description("Subscribe to another PressClaw instance's agent feed")
3658
+ .action(async (url?: string) => {
3659
+ const cfg = resolveConfig(api);
3660
+ const subs = readSubscriptions(cfg.notesDir);
3661
+
3662
+ // No args → list current subscriptions
3663
+ if (!url) {
3664
+ if (subs.length === 0) {
3665
+ console.log("No subscriptions yet.");
3666
+ console.log("Add one: openclaw notes subscribe <url>");
3667
+ return;
3668
+ }
3669
+ console.log(`📡 Subscriptions (${subs.length}):\n`);
3670
+ for (const sub of subs) {
3671
+ const checked = sub.last_checked ? `last checked ${sub.last_checked}` : "never checked";
3672
+ console.log(` ${sub.name}`);
3673
+ console.log(` ${sub.url}`);
3674
+ console.log(` Feed: ${sub.feed_url}`);
3675
+ console.log(` Added: ${sub.added} · ${checked}\n`);
3676
+ }
3677
+ return;
3678
+ }
3679
+
3680
+ // Normalize URL
3681
+ let baseUrl = url.replace(/\/+$/, "");
3682
+ if (!baseUrl.startsWith("http")) baseUrl = `https://${baseUrl}`;
3683
+
3684
+ // Check if already subscribed
3685
+ if (subs.find((s) => s.url === baseUrl)) {
3686
+ console.log(`Already subscribed to ${baseUrl}`);
3687
+ return;
3688
+ }
3689
+
3690
+ // Fetch well-known to validate
3691
+ console.log(`Fetching ${baseUrl}/.well-known/pressclaw.json ...`);
3692
+ try {
3693
+ const resp = await fetch(`${baseUrl}/.well-known/pressclaw.json`);
3694
+ if (!resp.ok) {
3695
+ console.log(`❌ Could not find PressClaw instance at ${baseUrl}`);
3696
+ console.log(` HTTP ${resp.status}: ${resp.statusText}`);
3697
+ return;
3698
+ }
3699
+ const wellKnown: any = await resp.json();
3700
+ if (!wellKnown.feed_url) {
3701
+ console.log(`❌ Invalid pressclaw.json — missing feed_url`);
3702
+ return;
3703
+ }
3704
+
3705
+ const sub: Subscription = {
3706
+ url: baseUrl,
3707
+ name: wellKnown.name || baseUrl,
3708
+ feed_url: wellKnown.feed_url,
3709
+ added: new Date().toISOString(),
3710
+ last_checked: null,
3711
+ };
3712
+ subs.push(sub);
3713
+ writeSubscriptions(cfg.notesDir, subs);
3714
+
3715
+ console.log(`✅ Subscribed to "${sub.name}"`);
3716
+ console.log(` Feed: ${sub.feed_url}`);
3717
+ if (wellKnown.post_count) console.log(` Posts: ${wellKnown.post_count}`);
3718
+ if (wellKnown.topics?.length) console.log(` Topics: ${wellKnown.topics.join(", ")}`);
3719
+ } catch (err: any) {
3720
+ console.log(`❌ Failed to connect to ${baseUrl}`);
3721
+ if (err?.message) console.log(` Error: ${err.message}`);
3722
+ }
3723
+ });
3724
+
3725
+ // ── unsubscribe ───────────────────────────────────────────────
3726
+ notes
3727
+ .command("unsubscribe <url>")
3728
+ .description("Remove a subscription")
3729
+ .action((url: string) => {
3730
+ const cfg = resolveConfig(api);
3731
+ let baseUrl = url.replace(/\/+$/, "");
3732
+ if (!baseUrl.startsWith("http")) baseUrl = `https://${baseUrl}`;
3733
+
3734
+ const subs = readSubscriptions(cfg.notesDir);
3735
+ const before = subs.length;
3736
+ const filtered = subs.filter((s) => s.url !== baseUrl);
3737
+ if (filtered.length === before) {
3738
+ console.log(`Not subscribed to ${baseUrl}`);
3739
+ return;
3740
+ }
3741
+ writeSubscriptions(cfg.notesDir, filtered);
3742
+ console.log(`✅ Unsubscribed from ${baseUrl}`);
3743
+ });
3744
+
3745
+ // ── digest ────────────────────────────────────────────────────
3746
+ notes
3747
+ .command("digest")
3748
+ .description("Fetch new posts from all subscribed feeds")
3749
+ .action(async () => {
3750
+ const cfg = resolveConfig(api);
3751
+ const subs = readSubscriptions(cfg.notesDir);
3752
+
3753
+ if (subs.length === 0) {
3754
+ console.log("No subscriptions. Add one: openclaw notes subscribe <url>");
3755
+ return;
3756
+ }
3757
+
3758
+ console.log(`📡 Checking ${subs.length} feed(s)...\n`);
3759
+
3760
+ let totalNew = 0;
3761
+ let feedsWithNew = 0;
3762
+ const allNewPosts: { source: string; title: string; topics: string[]; summary: string; url: string; published_at: string }[] = [];
3763
+
3764
+ for (const sub of subs) {
3765
+ try {
3766
+ const resp = await fetch(sub.feed_url);
3767
+ if (!resp.ok) {
3768
+ console.log(` ⚠️ ${sub.name}: HTTP ${resp.status}`);
3769
+ continue;
3770
+ }
3771
+ const feed: any = await resp.json();
3772
+ const posts: any[] = feed.posts || [];
3773
+
3774
+ // Filter to posts since last_checked
3775
+ const since = sub.last_checked ? new Date(sub.last_checked).getTime() : 0;
3776
+ const newPosts = posts.filter((p: any) => {
3777
+ const pubTime = new Date(p.published_at).getTime();
3778
+ return pubTime > since;
3779
+ });
3780
+
3781
+ if (newPosts.length > 0) {
3782
+ feedsWithNew++;
3783
+ totalNew += newPosts.length;
3784
+ for (const p of newPosts) {
3785
+ allNewPosts.push({
3786
+ source: sub.name,
3787
+ title: p.title,
3788
+ topics: p.topics || [],
3789
+ summary: p.summary || "",
3790
+ url: p.url || "",
3791
+ published_at: p.published_at,
3792
+ });
3793
+ }
3794
+ }
3795
+
3796
+ // Update last_checked
3797
+ sub.last_checked = new Date().toISOString();
3798
+ } catch (err: any) {
3799
+ console.log(` ⚠️ ${sub.name}: ${err?.message || "fetch failed"}`);
3800
+ }
3801
+ }
3802
+
3803
+ // Save updated timestamps
3804
+ writeSubscriptions(cfg.notesDir, subs);
3805
+
3806
+ if (totalNew === 0) {
3807
+ console.log("No new posts since last check.");
3808
+ return;
3809
+ }
3810
+
3811
+ console.log(`📬 ${totalNew} new post(s) from ${feedsWithNew} feed(s):\n`);
3812
+ for (const p of allNewPosts) {
3813
+ const topics = p.topics.length > 0 ? ` [${p.topics.join(", ")}]` : "";
3814
+ console.log(` 📝 "${p.title}" — ${p.source}${topics}`);
3815
+ console.log(` ${p.summary.slice(0, 120)}${p.summary.length > 120 ? "…" : ""}`);
3816
+ if (p.url) console.log(` ${p.url}`);
3817
+ console.log();
3818
+ }
3819
+ });
3820
+
3821
+ }, { commands: ["notes"] });
3822
+
3823
+ // ── HTTP handler ────────────────────────────────────────────────
3824
+
3825
+ const sendFile = (res: any, filePath: string, contentType: string) => {
3826
+ if (!fs.existsSync(filePath)) { res.statusCode = 404; res.end("Not found"); return; }
3827
+ res.statusCode = 200;
3828
+ res.setHeader("Content-Type", contentType);
3829
+ fs.createReadStream(filePath).pipe(res);
3830
+ };
3831
+
3832
+ api.registerHttpHandler((req: any, res: any) => {
3833
+ const cfg = resolveConfig(api);
3834
+ const url = new URL(req.url ?? "/", "http://localhost");
3835
+ const pathname = url.pathname;
3836
+
3837
+ // ── Private Dashboard at /dashboard ──────────────────────────
3838
+ if (pathname === "/dashboard" || pathname === "/dashboard/") {
3839
+ const html = renderDashboardHtml(cfg, pluginDir);
3840
+ res.statusCode = 200;
3841
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
3842
+ res.end(html);
3843
+ return true;
3844
+ }
3845
+
3846
+ // ── Agent Feed ────────────────────────────────────────────────
3847
+ if (pathname === "/feed/agent.json") {
3848
+ const feed = buildAgentFeed(cfg);
3849
+ const json = JSON.stringify(feed, null, 2);
3850
+ res.statusCode = 200;
3851
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
3852
+ res.setHeader("Access-Control-Allow-Origin", "*");
3853
+ res.end(json);
3854
+ return true;
3855
+ }
3856
+
3857
+ // ── Well-Known Discovery ───────────────────────────────────────
3858
+ if (pathname === "/.well-known/pressclaw.json") {
3859
+ const wellKnown = buildWellKnown(cfg);
3860
+ const json = JSON.stringify(wellKnown, null, 2);
3861
+ res.statusCode = 200;
3862
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
3863
+ res.setHeader("Access-Control-Allow-Origin", "*");
3864
+ res.end(json);
3865
+ return true;
3866
+ }
3867
+
3868
+ // ── Public blog ──────────────────────────────────────────────
3869
+ if (!pathname.startsWith(cfg.publicPath)) return false;
3870
+
3871
+ if (pathname === cfg.publicPath || pathname === `${cfg.publicPath}/`) {
3872
+ sendFile(res, path.join(cfg.outputDir, "index.html"), "text/html; charset=utf-8");
3873
+ return true;
3874
+ }
3875
+ if (pathname === `${cfg.publicPath}/rss.xml`) {
3876
+ sendFile(res, path.join(cfg.outputDir, "rss.xml"), "application/rss+xml; charset=utf-8");
3877
+ return true;
3878
+ }
3879
+
3880
+ const slug = pathname.replace(`${cfg.publicPath}/`, "");
3881
+ const file = path.join(cfg.outputDir, "posts", `${slug}.html`);
3882
+ sendFile(res, file, "text/html; charset=utf-8");
3883
+ return true;
3884
+ });
3885
+ }
3886
+
3887
+ // ── Private Dashboard Renderer ────────────────────────────────────
3888
+
3889
+ function renderDashboardHtml(cfg: any, pluginDir: string): string {
3890
+ const files = listNotes(cfg.notesDir);
3891
+ const topics = syncTopicStatus(cfg.notesDir, readTopics(cfg.notesDir));
3892
+ const styleProfile = loadStyleProfile(cfg.notesDir);
3893
+
3894
+ const ideas = topics.filter((t: Topic) => t.status === "idea");
3895
+ const drafted = topics.filter((t: Topic) => t.status === "drafted");
3896
+ const refined = topics.filter((t: Topic) => t.status === "refined");
3897
+ const published = topics.filter((t: Topic) => t.status === "published");
3898
+
3899
+ // Load named style profiles
3900
+ const profilesDir = path.join(cfg.notesDir, ".style-profiles");
3901
+ const namedProfiles: { name: string; voice: string; pieces: number; updated: string }[] = [];
3902
+ if (fs.existsSync(profilesDir)) {
3903
+ for (const pf of fs.readdirSync(profilesDir).filter((f: string) => f.endsWith(".json"))) {
3904
+ try {
3905
+ const p = JSON.parse(fs.readFileSync(path.join(profilesDir, pf), "utf8"));
3906
+ namedProfiles.push({
3907
+ name: p.name || pf.replace(/\.json$/, ""),
3908
+ voice: p.voiceDescription || "",
3909
+ pieces: p.analyzedPieces?.length || 0,
3910
+ updated: p.updated || "—",
3911
+ });
3912
+ } catch {}
3913
+ }
3914
+ }
3915
+
3916
+ // Load test results per slug
3917
+ const testResults: Record<string, { confidence: number; tested: string; topSuggestion: string; scores: Record<string, { score: number; sentiment: string }> }> = {};
3918
+ const varsDir = path.join(cfg.notesDir, ".variations");
3919
+ if (fs.existsSync(varsDir)) {
3920
+ for (const d of fs.readdirSync(varsDir)) {
3921
+ const trFile = path.join(varsDir, d, "_test-results.json");
3922
+ if (fs.existsSync(trFile)) {
3923
+ try {
3924
+ const tr = JSON.parse(fs.readFileSync(trFile, "utf8"));
3925
+ testResults[d] = {
3926
+ confidence: tr.confidence,
3927
+ tested: tr.tested,
3928
+ topSuggestion: tr.topSuggestion || "",
3929
+ scores: tr.scores || {},
3930
+ };
3931
+ } catch {}
3932
+ }
3933
+ }
3934
+ }
3935
+
3936
+ // Load all feedback data
3937
+ const allFeedback = loadAllFeedback(cfg.notesDir);
3938
+ const feedbackBySlug: Record<string, FeedbackFile> = {};
3939
+ for (const fb of allFeedback) feedbackBySlug[fb.slug] = fb;
3940
+
3941
+ // Gather note details
3942
+ const noteDetails: { slug: string; title: string; status: string; confidence: number | null; variations: number; wordCount: number; tone: string | null; structure: string | null; topicStatus: string; testResult: typeof testResults[string] | null; styleMarkers: Record<string, any> | null; feedback: FeedbackFile | null }[] = [];
3943
+ for (const f of files) {
3944
+ const full = fs.readFileSync(path.join(cfg.notesDir, f), "utf8");
3945
+ const { meta, body } = parseFrontMatter(full);
3946
+ const slug = f.replace(/\.md$/, "");
3947
+ const wc = body.split(/\s+/).filter((w: string) => w.length > 0).length;
3948
+ const topic = topics.find((t: Topic) => t.slug === slug);
3949
+ let sm: Record<string, any> | null = null;
3950
+ if (meta.style_markers) {
3951
+ try { sm = typeof meta.style_markers === "string" ? JSON.parse(meta.style_markers) : meta.style_markers; } catch {}
3952
+ }
3953
+ noteDetails.push({
3954
+ slug,
3955
+ title: meta.title || slug,
3956
+ status: meta.status || "private",
3957
+ confidence: meta.confidence ? parseFloat(meta.confidence) : null,
3958
+ variations: _variationCount(cfg.notesDir, slug),
3959
+ wordCount: wc,
3960
+ tone: meta.tone || null,
3961
+ structure: meta.structure || null,
3962
+ topicStatus: topic?.status || "untracked",
3963
+ testResult: testResults[slug] || null,
3964
+ styleMarkers: sm,
3965
+ feedback: feedbackBySlug[slug] || null,
3966
+ });
3967
+ }
3968
+
3969
+ const statusIcon = (s: string) => s === "public" ? "📢" : s === "refined" ? "✨" : "🔒";
3970
+ const confBar = (c: number | null) => {
3971
+ if (c === null || isNaN(c)) return '<span style="color:#a1a1aa">—</span>';
3972
+ const pct = Math.round(c * 10);
3973
+ const color = c >= 7 ? "#22c55e" : c >= 5 ? "#eab308" : "#ef4444";
3974
+ 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>`;
3975
+ };
3976
+
3977
+ const noteRows = noteDetails
3978
+ .sort((a, b) => {
3979
+ const order: Record<string, number> = { public: 0, refined: 1, private: 2 };
3980
+ return (order[a.status] ?? 3) - (order[b.status] ?? 3);
3981
+ })
3982
+ .map((n) => {
3983
+ const testScores = n.testResult ? Object.entries(n.testResult.scores).map(([k, v]: [string, any]) => {
3984
+ const emoji = v.score >= 7 ? "🟢" : v.score >= 5 ? "🟡" : "🔴";
3985
+ return `${emoji}${v.score}`;
3986
+ }).join(" ") : "";
3987
+ const sm = n.styleMarkers;
3988
+ const markersHtml = sm
3989
+ ? `<span style="font-size:0.75em;color:#a1a1aa">${sm.avgSentenceLength}w/sent · ${sm.avgParagraphSentences}s/¶ · ${sm.readabilityScore} FK · ${sm.perspective}</span>`
3990
+ : '<span style="color:#3f3f46;font-size:0.75em">—</span>';
3991
+ const fbHtml = n.feedback
3992
+ ? `<span style="font-size:0.95em;font-weight:600;color:${n.feedback.aggregate.avgScore >= 7 ? "#22c55e" : n.feedback.aggregate.avgScore >= 5 ? "#eab308" : "#ef4444"}">${n.feedback.aggregate.avgScore}/10</span><br><span style="font-size:0.75em;color:#71717a">${n.feedback.aggregate.totalViews}v · ${n.feedback.aggregate.totalLikes}♥ · ${n.feedback.aggregate.totalShares}↗</span>`
3993
+ : '<span style="color:#3f3f46">—</span>';
3994
+ const twitterEntry = n.feedback?.entries?.find((e) => e.platform === "twitter");
3995
+ const engagementHtml = twitterEntry
3996
+ ? `<br><span style="font-size:0.7em;color:#71717a" title="Last fetched engagement">📊 ${twitterEntry.metrics.views || 0}v · ${twitterEntry.metrics.likes || 0}♥ · ${twitterEntry.metrics.shares || 0}↗ · ${twitterEntry.metrics.comments || 0}💬</span>`
3997
+ : "";
3998
+ const twitterPosts = n.feedback?.tweets?.filter((t: any) => !t.platform || t.platform === "twitter") || [];
3999
+ const tweetHtml = twitterPosts.length
4000
+ ? `<a href="https://x.com/pressclawai/status/${twitterPosts[twitterPosts.length - 1].id}" target="_blank" style="text-decoration:none" title="Posted ${twitterPosts.length} tweet(s)">🐦 ${twitterPosts.length}</a>${engagementHtml}`
4001
+ : '<span style="color:#3f3f46">—</span>';
4002
+ const linkedinPosts = n.feedback?.tweets?.filter((t: any) => t.platform === "linkedin") || [];
4003
+ const linkedinHtml = linkedinPosts.length
4004
+ ? `<a href="https://www.linkedin.com/feed/update/${linkedinPosts[linkedinPosts.length - 1].id}" target="_blank" style="text-decoration:none" title="Posted ${linkedinPosts.length} LinkedIn post(s)">💼 ${linkedinPosts.length}</a>`
4005
+ : '<span style="color:#3f3f46">—</span>';
4006
+ return `<tr>
4007
+ <td>${statusIcon(n.status)}</td>
4008
+ <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">💡 ${escapeHtml(n.testResult.topSuggestion.slice(0, 120))}${n.testResult.topSuggestion.length > 120 ? "…" : ""}</span>` : ""}</td>
4009
+ <td>${n.status}</td>
4010
+ <td>${n.wordCount}w</td>
4011
+ <td>${n.tone || "—"}</td>
4012
+ <td>${n.structure || "—"}</td>
4013
+ <td>${markersHtml}</td>
4014
+ <td>${n.variations > 0 ? n.variations + " vars" : "—"}</td>
4015
+ <td>${fbHtml}</td>
4016
+ <td>${tweetHtml}</td>
4017
+ <td>${linkedinHtml}</td>
4018
+ <td>${confBar(n.confidence)}${testScores ? `<br><span style="font-size:0.75em;color:#71717a">${testScores}</span>` : ""}</td>
4019
+ </tr>`;
4020
+ }).join("\n");
4021
+
4022
+ const ideaRows = ideas.map((t: Topic) => {
4023
+ const tags = t.tags?.length ? t.tags.map((tg: string) => `<span style="background:#27272a;color:#a1a1aa;padding:1px 6px;border-radius:3px;font-size:0.8em">${escapeHtml(tg)}</span>`).join(" ") : "";
4024
+ 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>`;
4025
+ }).join("\n");
4026
+
4027
+ return `<!doctype html>
4028
+ <html lang="en">
4029
+ <head>
4030
+ <meta charset="utf-8" />
4031
+ <title>Dashboard — ${cfg.siteTitle}</title>
4032
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
4033
+ <style>
4034
+ * { box-sizing: border-box; margin: 0; padding: 0; }
4035
+ 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; }
4036
+ h1 { font-size: 1.6em; margin-bottom: 8px; color: #fafafa; }
4037
+ h2 { font-size: 1.2em; margin: 32px 0 12px; color: #a1a1aa; font-weight: 500; border-bottom: 1px solid #27272a; padding-bottom: 6px; }
4038
+ .stats { display: flex; gap: 16px; margin: 16px 0 24px; flex-wrap: wrap; }
4039
+ .stat { background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 12px 20px; min-width: 100px; }
4040
+ .stat .num { font-size: 1.8em; font-weight: 700; color: #fafafa; }
4041
+ .stat .label { font-size: 0.8em; color: #71717a; }
4042
+ .style-box { background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 16px 20px; margin: 12px 0; }
4043
+ .style-box .voice { font-style: italic; color: #d4d4d8; margin: 8px 0; }
4044
+ .style-box .avoid { color: #f87171; font-size: 0.9em; }
4045
+ table { width: 100%; border-collapse: collapse; margin: 8px 0; }
4046
+ th { text-align: left; color: #71717a; font-size: 0.8em; font-weight: 500; padding: 6px 8px; border-bottom: 1px solid #27272a; }
4047
+ td { padding: 8px; border-bottom: 1px solid #1a1a1e; vertical-align: top; font-size: 0.9em; }
4048
+ tr:hover { background: #18181b; }
4049
+ .footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #27272a; color: #52525b; font-size: 0.8em; }
4050
+ a { color: #60a5fa; text-decoration: none; }
4051
+ a:hover { text-decoration: underline; }
4052
+ </style>
4053
+ </head>
4054
+ <body>
4055
+ <h1>📊 ${escapeHtml(cfg.siteTitle)} — Dashboard</h1>
4056
+ <p style="color:#71717a;margin-bottom:8px">Private pipeline view · <a href="${cfg.publicPath}">Public blog →</a></p>
4057
+
4058
+ <div class="stats">
4059
+ <div class="stat"><div class="num">${published.length}</div><div class="label">📢 Published</div></div>
4060
+ <div class="stat"><div class="num">${refined.length}</div><div class="label">✨ Refined</div></div>
4061
+ <div class="stat"><div class="num">${drafted.length}</div><div class="label">📝 Drafted</div></div>
4062
+ <div class="stat"><div class="num">${ideas.length}</div><div class="label">💡 Ideas</div></div>
4063
+ <div class="stat"><div class="num">${files.length}</div><div class="label">📄 Files</div></div>
4064
+ </div>
4065
+
4066
+ ${styleProfile ? `
4067
+ <h2>🎨 Style Profile</h2>
4068
+ <div class="style-box">
4069
+ <div class="voice">"${escapeHtml(styleProfile.voiceDescription)}"</div>
4070
+ <div style="margin-top:8px;font-size:0.85em;color:#a1a1aa">
4071
+ Analyzed ${styleProfile.analyzedNotes.length} notes · Updated ${styleProfile.updated}
4072
+ </div>
4073
+ ${styleProfile.avoid?.length ? `<div class="avoid" style="margin-top:6px">Avoid: ${styleProfile.avoid.map((a: string) => escapeHtml(a)).join(", ")}</div>` : ""}
4074
+ </div>
4075
+ ` : `<h2>🎨 Style Profile</h2><p style="color:#71717a">No style profile yet. Run <code>openclaw notes style</code> to create one.</p>`}
4076
+
4077
+ ${namedProfiles.length > 0 ? `
4078
+ <h2>🎭 Voice Personas (${namedProfiles.length})</h2>
4079
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
4080
+ ${namedProfiles.map((p) => `
4081
+ <div class="style-box" style="flex:1;min-width:280px">
4082
+ <div style="font-weight:600;color:#fafafa;margin-bottom:4px">${escapeHtml(p.name)}</div>
4083
+ <div class="voice" style="font-size:0.85em">"${escapeHtml(p.voice.slice(0, 200))}${p.voice.length > 200 ? "…" : ""}"</div>
4084
+ <div style="font-size:0.8em;color:#52525b;margin-top:6px">${p.pieces} pieces analyzed · ${p.updated}</div>
4085
+ </div>`).join("")}
4086
+ </div>
4087
+ ` : ""}
4088
+
4089
+ <h2>📝 Notes Pipeline</h2>
4090
+ <table>
4091
+ <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>Score</th><th>🐦</th><th>💼</th><th>Confidence</th></tr></thead>
4092
+ <tbody>${noteRows}</tbody>
4093
+ </table>
4094
+
4095
+ ${_renderEvolutionHtml(styleProfile)}
4096
+
4097
+ ${_renderPerformanceHtml(cfg.notesDir, allFeedback, noteDetails)}
4098
+
4099
+ ${_renderAgentFeedHtml(cfg)}
4100
+
4101
+ ${ideas.length > 0 ? `
4102
+ <h2>💡 Topic Backlog (${ideas.length})</h2>
4103
+ <table>
4104
+ <thead><tr><th>ID</th><th>Title</th><th>Tags</th><th>Added</th></tr></thead>
4105
+ <tbody>${ideaRows}</tbody>
4106
+ </table>
4107
+ ` : ""}
4108
+
4109
+ <div class="footer">
4110
+ Generated ${new Date().toISOString()} · <a href="${cfg.publicPath}/rss.xml">RSS</a> · <a href="/feed/agent.json">Agent Feed</a>
4111
+ </div>
4112
+ </body>
4113
+ </html>`;
4114
+ }
4115
+
4116
+ // ── Dashboard helpers (outside register) ──────────────────────────
4117
+
4118
+ function _renderAgentFeedHtml(cfg: any): string {
4119
+ const subs = readSubscriptions(cfg.notesDir);
4120
+ const feedUrl = cfg.baseUrl ? `${cfg.baseUrl}/feed/agent.json` : "/feed/agent.json";
4121
+ const wellKnownUrl = cfg.baseUrl ? `${cfg.baseUrl}/.well-known/pressclaw.json` : "/.well-known/pressclaw.json";
4122
+
4123
+ // Read last digest date from subscriptions
4124
+ let lastDigest = "never";
4125
+ for (const sub of subs) {
4126
+ if (sub.last_checked && (lastDigest === "never" || sub.last_checked > lastDigest)) {
4127
+ lastDigest = sub.last_checked;
4128
+ }
4129
+ }
4130
+
4131
+ const subRows = subs.length > 0 ? subs.map((s) => {
4132
+ const checked = s.last_checked ? new Date(s.last_checked).toISOString().slice(0, 16).replace("T", " ") : "never";
4133
+ return `<tr><td>${escapeHtml(s.name)}</td><td><a href="${escapeHtml(s.url)}">${escapeHtml(s.url)}</a></td><td>${checked}</td></tr>`;
4134
+ }).join("\n") : `<tr><td colspan="3" style="color:#52525b">No subscriptions yet. Run: <code>openclaw notes subscribe &lt;url&gt;</code></td></tr>`;
4135
+
4136
+ return `
4137
+ <h2>📡 Agent Feed</h2>
4138
+ <div class="style-box">
4139
+ <div style="display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px">
4140
+ <div><span style="color:#71717a;font-size:0.8em">Feed URL</span><br><a href="${escapeHtml(feedUrl)}" style="font-size:0.9em">${escapeHtml(feedUrl)}</a></div>
4141
+ <div><span style="color:#71717a;font-size:0.8em">Discovery</span><br><a href="${escapeHtml(wellKnownUrl)}" style="font-size:0.9em">.well-known/pressclaw.json</a></div>
4142
+ <div><span style="color:#71717a;font-size:0.8em">Subscriptions</span><br><span style="font-size:1.2em;font-weight:600">${subs.length}</span></div>
4143
+ <div><span style="color:#71717a;font-size:0.8em">Last Digest</span><br><span style="font-size:0.9em">${lastDigest === "never" ? "never" : new Date(lastDigest).toISOString().slice(0, 16).replace("T", " ")}</span></div>
4144
+ </div>
4145
+ ${subs.length > 0 ? `
4146
+ <table style="margin-top:8px">
4147
+ <thead><tr><th>Name</th><th>URL</th><th>Last Checked</th></tr></thead>
4148
+ <tbody>${subRows}</tbody>
4149
+ </table>` : ""}
4150
+ </div>`;
4151
+ }
4152
+
4153
+ function _renderEvolutionHtml(styleProfile: any): string {
4154
+ if (!styleProfile?.evolution?.length || styleProfile.evolution.length < 2) return "";
4155
+
4156
+ const evo = styleProfile.evolution;
4157
+ const latest = evo[evo.length - 1];
4158
+ const first = evo[0];
4159
+ const slDelta = (latest.avgSentenceLength - first.avgSentenceLength).toFixed(1);
4160
+ const wcDelta = Math.round(latest.avgWordCount - first.avgWordCount);
4161
+ const rdDelta = Math.round(latest.avgReadability - first.avgReadability);
4162
+ const slArrow = Number(slDelta) > 0 ? "↑" : Number(slDelta) < 0 ? "↓" : "→";
4163
+ const wcArrow = wcDelta > 0 ? "↑" : wcDelta < 0 ? "↓" : "→";
4164
+ const rdArrow = rdDelta > 0 ? "↑" : rdDelta < 0 ? "↓" : "→";
4165
+ const slColor = Number(slDelta) === 0 ? "#71717a" : "#60a5fa";
4166
+ const wcColor = wcDelta === 0 ? "#71717a" : "#60a5fa";
4167
+ const rdColor = rdDelta === 0 ? "#71717a" : "#60a5fa";
4168
+
4169
+ // Mini sparkline bars
4170
+ const maxWc = Math.max(...evo.map((e: any) => e.avgWordCount || 0));
4171
+ const recentEvo = evo.slice(-10);
4172
+ const sparkBars = recentEvo.map((e: any) => {
4173
+ const pct = maxWc > 0 ? Math.round(((e.avgWordCount || 0) / maxWc) * 100) : 0;
4174
+ const h = Math.max(4, pct * 0.4);
4175
+ return '<div style="width:8px;height:' + h + 'px;background:#60a5fa;border-radius:2px" title="' + e.date + ': ' + Math.round(e.avgWordCount) + 'w"></div>';
4176
+ }).join("");
4177
+
4178
+ return '<h2>📈 Style Evolution</h2>\n<div class="style-box">' +
4179
+ '<div style="display:flex;gap:24px;flex-wrap:wrap;margin-bottom:12px">' +
4180
+ '<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>' +
4181
+ '<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>' +
4182
+ '<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>' +
4183
+ '<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>' +
4184
+ '</div>' +
4185
+ '<div style="display:flex;align-items:end;gap:3px;height:24px">' + sparkBars + '</div>' +
4186
+ '<div style="font-size:0.75em;color:#52525b;margin-top:4px">Word count trend (last ' + recentEvo.length + ' snapshots) · First: ' + first.date + ' · Latest: ' + latest.date + '</div>' +
4187
+ '</div>';
4188
+ }
4189
+
4190
+ function _renderPerformanceHtml(notesDir: string, allFeedback: FeedbackFile[], noteDetails: { slug: string; title: string; tone: string | null; structure: string | null; feedback: FeedbackFile | null; styleMarkers: Record<string, any> | null }[]): string {
4191
+ if (allFeedback.length === 0) return "";
4192
+
4193
+ // Top performers
4194
+ const withFeedback = noteDetails.filter((n) => n.feedback && n.feedback.aggregate.entries > 0);
4195
+ if (withFeedback.length === 0) return "";
4196
+
4197
+ const topPerformers = [...withFeedback]
4198
+ .sort((a, b) => (b.feedback!.aggregate.avgScore) - (a.feedback!.aggregate.avgScore))
4199
+ .slice(0, 5);
4200
+
4201
+ const topRows = topPerformers.map((n) => {
4202
+ const fb = n.feedback!;
4203
+ const scoreColor = fb.aggregate.avgScore >= 7 ? "#22c55e" : fb.aggregate.avgScore >= 5 ? "#eab308" : "#ef4444";
4204
+ return `<tr>
4205
+ <td style="font-weight:600;color:${scoreColor}">${fb.aggregate.avgScore}/10</td>
4206
+ <td><strong>${escapeHtml(n.title)}</strong><br><span style="color:#71717a;font-size:0.85em">${n.slug}</span></td>
4207
+ <td>${fb.aggregate.totalViews}</td>
4208
+ <td>${fb.aggregate.totalLikes}</td>
4209
+ <td>${fb.aggregate.totalShares}</td>
4210
+ <td>${fb.aggregate.entries}</td>
4211
+ </tr>`;
4212
+ }).join("\n");
4213
+
4214
+ // Style correlation summary
4215
+ const correlations: string[] = [];
4216
+
4217
+ // Tone correlation
4218
+ const toneGroups: Record<string, { total: number; count: number }> = {};
4219
+ for (const n of withFeedback) {
4220
+ if (!n.tone) continue;
4221
+ if (!toneGroups[n.tone]) toneGroups[n.tone] = { total: 0, count: 0 };
4222
+ toneGroups[n.tone].total += n.feedback!.aggregate.avgScore;
4223
+ toneGroups[n.tone].count += 1;
4224
+ }
4225
+ const toneEntries = Object.entries(toneGroups).map(([k, v]) => ({ tone: k, avg: Math.round((v.total / v.count) * 10) / 10 })).sort((a, b) => b.avg - a.avg);
4226
+ if (toneEntries.length >= 2) {
4227
+ correlations.push(`🎭 <strong>${escapeHtml(toneEntries[0].tone)}</strong> tone scores ${toneEntries[0].avg} avg vs <strong>${escapeHtml(toneEntries[toneEntries.length - 1].tone)}</strong> at ${toneEntries[toneEntries.length - 1].avg}`);
4228
+ }
4229
+
4230
+ // Structure correlation
4231
+ const structGroups: Record<string, { total: number; count: number }> = {};
4232
+ for (const n of withFeedback) {
4233
+ if (!n.structure) continue;
4234
+ if (!structGroups[n.structure]) structGroups[n.structure] = { total: 0, count: 0 };
4235
+ structGroups[n.structure].total += n.feedback!.aggregate.avgScore;
4236
+ structGroups[n.structure].count += 1;
4237
+ }
4238
+ const structEntries = Object.entries(structGroups).map(([k, v]) => ({ structure: k, avg: Math.round((v.total / v.count) * 10) / 10 })).sort((a, b) => b.avg - a.avg);
4239
+ if (structEntries.length >= 2) {
4240
+ const delta = Math.round((structEntries[0].avg - structEntries[structEntries.length - 1].avg) * 10) / 10;
4241
+ correlations.push(`🏗️ <strong>${escapeHtml(structEntries[0].structure)}</strong> structure scores ${delta} points higher than <strong>${escapeHtml(structEntries[structEntries.length - 1].structure)}</strong>`);
4242
+ }
4243
+
4244
+ // Sentence length correlation
4245
+ const withMarkers = withFeedback.filter((n) => n.styleMarkers?.avgSentenceLength);
4246
+ if (withMarkers.length >= 2) {
4247
+ const sorted = [...withMarkers].sort((a, b) => a.feedback!.aggregate.avgScore - b.feedback!.aggregate.avgScore);
4248
+ const topHalf = sorted.slice(Math.floor(sorted.length / 2));
4249
+ const bottomHalf = sorted.slice(0, Math.floor(sorted.length / 2));
4250
+ if (topHalf.length > 0 && bottomHalf.length > 0) {
4251
+ const topSL = Math.round(topHalf.reduce((s, n) => s + n.styleMarkers!.avgSentenceLength, 0) / topHalf.length * 10) / 10;
4252
+ const botSL = Math.round(bottomHalf.reduce((s, n) => s + n.styleMarkers!.avgSentenceLength, 0) / bottomHalf.length * 10) / 10;
4253
+ correlations.push(`📏 Top-rated posts avg <strong>${topSL}</strong> words/sentence (vs ${botSL} for lowest)`);
4254
+ }
4255
+ }
4256
+
4257
+ // Platform comparison
4258
+ const platformMetrics: Record<string, { shares: number; count: number }> = {};
4259
+ for (const fb of allFeedback) {
4260
+ for (const e of fb.entries) {
4261
+ if (!e.platform) continue;
4262
+ if (!platformMetrics[e.platform]) platformMetrics[e.platform] = { shares: 0, count: 0 };
4263
+ platformMetrics[e.platform].shares += e.metrics.shares || 0;
4264
+ platformMetrics[e.platform].count += 1;
4265
+ }
4266
+ }
4267
+ const platEntries = Object.entries(platformMetrics).filter(([, pm]) => pm.count > 0).map(([p, pm]) => ({ platform: p, avgShares: Math.round(pm.shares / pm.count * 10) / 10 })).sort((a, b) => b.avgShares - a.avgShares);
4268
+ if (platEntries.length >= 2 && platEntries[platEntries.length - 1].avgShares > 0) {
4269
+ const ratio = Math.round(platEntries[0].avgShares / platEntries[platEntries.length - 1].avgShares * 10) / 10;
4270
+ correlations.push(`🌐 <strong>${escapeHtml(platEntries[0].platform)}</strong> gets ${ratio}x more shares than ${escapeHtml(platEntries[platEntries.length - 1].platform)}`);
4271
+ }
4272
+
4273
+ const corrHtml = correlations.length > 0
4274
+ ? `<div class="style-box" style="margin-top:12px"><div style="font-size:0.85em;color:#a1a1aa;margin-bottom:8px">Style Correlations</div>${correlations.map((c) => `<div style="margin:4px 0;font-size:0.9em">${c}</div>`).join("")}</div>`
4275
+ : "";
4276
+
4277
+ return `<h2>📈 Performance (${withFeedback.length} notes tracked)</h2>
4278
+ <table>
4279
+ <thead><tr><th>Score</th><th>Title</th><th>Views</th><th>Likes</th><th>Shares</th><th>Entries</th></tr></thead>
4280
+ <tbody>${topRows}</tbody>
4281
+ </table>
4282
+ ${corrHtml}`;
4283
+ }
4284
+
4285
+ function _noteConfidence(notesDir: string, slug: string | null | undefined): number | null {
4286
+ if (!slug) return null;
4287
+ const file = path.join(notesDir, `${slug}.md`);
4288
+ if (!fs.existsSync(file)) return null;
4289
+ const { meta } = parseFrontMatter(fs.readFileSync(file, "utf8"));
4290
+ const c = parseFloat(meta.confidence);
4291
+ return isNaN(c) ? null : c;
4292
+ }
4293
+
4294
+ function _variationCount(notesDir: string, slug: string | null | undefined): number {
4295
+ if (!slug) return 0;
4296
+ const manifest = loadManifest(notesDir, slug);
4297
+ return manifest?.variations?.length || 0;
4298
+ }