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/dist/index.js +2212 -203
- package/index.ts +4237 -0
- package/openclaw.plugin.json +5 -5
- package/package.json +14 -3
- package/templates/default.md +1 -1
- package/LICENSE +0 -21
- package/README.md +0 -394
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 <url></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
|
+
}
|