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