codeblog-mcp 2.5.1 → 2.6.1

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 CHANGED
File without changes
@@ -0,0 +1,18 @@
1
+ export interface PreviewData {
2
+ id: string;
3
+ title: string;
4
+ content: string;
5
+ tags: string[];
6
+ summary: string;
7
+ category: string;
8
+ source_session: string;
9
+ language: string;
10
+ mode: "manual" | "auto" | "digest";
11
+ createdAt: number;
12
+ /** auto_post session id for dedup tracking */
13
+ sessionId?: string;
14
+ }
15
+ export declare function generatePreviewId(): string;
16
+ export declare function savePreview(data: PreviewData): void;
17
+ export declare function getPreview(id: string): PreviewData | null;
18
+ export declare function deletePreview(id: string): void;
@@ -0,0 +1,29 @@
1
+ const store = new Map();
2
+ const TTL_MS = 30 * 60 * 1000; // 30 minutes
3
+ export function generatePreviewId() {
4
+ return `pv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
5
+ }
6
+ export function savePreview(data) {
7
+ cleanup();
8
+ store.set(data.id, data);
9
+ }
10
+ export function getPreview(id) {
11
+ const data = store.get(id);
12
+ if (!data)
13
+ return null;
14
+ if (Date.now() - data.createdAt > TTL_MS) {
15
+ store.delete(id);
16
+ return null;
17
+ }
18
+ return data;
19
+ }
20
+ export function deletePreview(id) {
21
+ store.delete(id);
22
+ }
23
+ function cleanup() {
24
+ const now = Date.now();
25
+ for (const [id, data] of store) {
26
+ if (now - data.createdAt > TTL_MS)
27
+ store.delete(id);
28
+ }
29
+ }
@@ -5,12 +5,379 @@ import { getUrl, getLanguage, text, CONFIG_DIR } from "../lib/config.js";
5
5
  import { withAuth, requireAuth, isAuthError } from "../lib/auth-guard.js";
6
6
  import { scanAll, parseSession } from "../lib/registry.js";
7
7
  import { analyzeSession } from "../lib/analyzer.js";
8
+ import { generatePreviewId, savePreview, getPreview, deletePreview } from "../lib/preview-store.js";
9
+ function buildAutoPost(source, style) {
10
+ // 1. Scan sessions
11
+ const sessions = scanAll(30, source || undefined);
12
+ if (sessions.length === 0) {
13
+ return { content: [text("No coding sessions found. Use an AI IDE (Claude Code, Cursor, etc.) first.")], isError: true };
14
+ }
15
+ // 2. Filter: only sessions with enough substance
16
+ const candidates = sessions.filter((s) => s.messageCount >= 4 && s.humanMessages >= 2 && s.sizeBytes > 1024);
17
+ if (candidates.length === 0) {
18
+ return { content: [text("No sessions with enough content to post about. Need at least 4 messages and 2 human messages.")], isError: true };
19
+ }
20
+ // 3. Check what we've already posted (dedup via local tracking file)
21
+ const postedFile = path.join(CONFIG_DIR, "posted_sessions.json");
22
+ let postedSessions = new Set();
23
+ try {
24
+ if (fs.existsSync(postedFile)) {
25
+ const data = JSON.parse(fs.readFileSync(postedFile, "utf-8"));
26
+ if (Array.isArray(data))
27
+ postedSessions = new Set(data);
28
+ }
29
+ }
30
+ catch { }
31
+ const unposted = candidates.filter((s) => !postedSessions.has(s.id));
32
+ if (unposted.length === 0) {
33
+ return { content: [text("All recent sessions have already been posted about! Come back after more coding sessions.")], isError: true };
34
+ }
35
+ // 4. Pick the best session (most recent with most substance)
36
+ const best = unposted[0]; // Already sorted by most recent
37
+ // 5. Parse and analyze
38
+ const parsed = parseSession(best.filePath, best.source);
39
+ if (!parsed || parsed.turns.length === 0) {
40
+ return { content: [text(`Could not parse session: ${best.filePath}`)], isError: true };
41
+ }
42
+ const analysis = analyzeSession(parsed);
43
+ // 6. Quality check
44
+ if (analysis.topics.length === 0 && analysis.languages.length === 0) {
45
+ return { content: [text("Session doesn't contain enough technical content to post. Try a different session.")], isError: true };
46
+ }
47
+ // 7. Generate post content
48
+ const postStyle = style || (analysis.problems.length > 0 ? "bug-story" : analysis.keyInsights.length > 0 ? "til" : "deep-dive");
49
+ const title = analysis.suggestedTitle.length > 10
50
+ ? analysis.suggestedTitle.slice(0, 80)
51
+ : `${analysis.topics.slice(0, 2).join(" + ")} in ${best.project}`;
52
+ let postContent = "";
53
+ postContent += `${analysis.summary}\n\n`;
54
+ if (analysis.problems.length > 0) {
55
+ postContent += `## The problem\n\n`;
56
+ if (analysis.problems.length === 1) {
57
+ postContent += `${analysis.problems[0]}\n\n`;
58
+ }
59
+ else {
60
+ analysis.problems.forEach((p) => { postContent += `- ${p}\n`; });
61
+ postContent += `\n`;
62
+ }
63
+ }
64
+ if (analysis.solutions.length > 0) {
65
+ const fixHeader = analysis.problems.length > 0 ? "How I fixed it" : "What I ended up doing";
66
+ postContent += `## ${fixHeader}\n\n`;
67
+ if (analysis.solutions.length === 1) {
68
+ postContent += `${analysis.solutions[0]}\n\n`;
69
+ }
70
+ else {
71
+ analysis.solutions.forEach((s) => { postContent += `- ${s}\n`; });
72
+ postContent += `\n`;
73
+ }
74
+ }
75
+ if (analysis.codeSnippets.length > 0) {
76
+ const snippet = analysis.codeSnippets[0];
77
+ postContent += `## Show me the code\n\n`;
78
+ if (snippet.context)
79
+ postContent += `${snippet.context}\n\n`;
80
+ postContent += `\`\`\`${snippet.language}\n${snippet.code}\n\`\`\`\n\n`;
81
+ if (analysis.codeSnippets.length > 1) {
82
+ const snippet2 = analysis.codeSnippets[1];
83
+ if (snippet2.context)
84
+ postContent += `${snippet2.context}\n\n`;
85
+ postContent += `\`\`\`${snippet2.language}\n${snippet2.code}\n\`\`\`\n\n`;
86
+ }
87
+ }
88
+ if (analysis.keyInsights.length > 0) {
89
+ postContent += `## What I learned\n\n`;
90
+ analysis.keyInsights.slice(0, 4).forEach((i) => { postContent += `- ${i}\n`; });
91
+ postContent += `\n`;
92
+ }
93
+ const langStr = analysis.languages.length > 0 ? analysis.languages.join(", ") : "";
94
+ postContent += `---\n\n`;
95
+ postContent += `*${best.source} session`;
96
+ if (langStr)
97
+ postContent += ` · ${langStr}`;
98
+ postContent += ` · ${best.project}*\n`;
99
+ const categoryMap = {
100
+ "bug-story": "bugs", "war-story": "bugs", "til": "til",
101
+ "how-to": "patterns", "quick-tip": "til", "opinion": "general",
102
+ "deep-dive": "general", "code-review": "patterns",
103
+ };
104
+ const category = categoryMap[postStyle] || "general";
105
+ return {
106
+ title,
107
+ content: postContent,
108
+ tags: analysis.suggestedTags,
109
+ summary: analysis.summary.slice(0, 200),
110
+ category,
111
+ sourceSession: best.filePath,
112
+ sessionId: best.id,
113
+ };
114
+ }
115
+ function buildDigest() {
116
+ const sessions = scanAll(50);
117
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
118
+ const recentSessions = sessions.filter((s) => s.modifiedAt >= sevenDaysAgo);
119
+ if (recentSessions.length === 0) {
120
+ return { content: [text("No coding sessions found in the last 7 days. Come back after some coding!")], isError: true };
121
+ }
122
+ const allLanguages = new Set();
123
+ const allTopics = new Set();
124
+ const allTags = new Set();
125
+ const allProblems = [];
126
+ const allInsights = [];
127
+ const projectSet = new Set();
128
+ const sourceSet = new Set();
129
+ let totalTurns = 0;
130
+ for (const session of recentSessions) {
131
+ projectSet.add(session.project);
132
+ sourceSet.add(session.source);
133
+ totalTurns += session.messageCount;
134
+ const parsed = parseSession(session.filePath, session.source, 30);
135
+ if (!parsed || parsed.turns.length === 0)
136
+ continue;
137
+ const analysis = analyzeSession(parsed);
138
+ analysis.languages.forEach((l) => allLanguages.add(l));
139
+ analysis.topics.forEach((t) => allTopics.add(t));
140
+ analysis.suggestedTags.forEach((t) => allTags.add(t));
141
+ allProblems.push(...analysis.problems.slice(0, 2));
142
+ allInsights.push(...analysis.keyInsights.slice(0, 2));
143
+ }
144
+ const projects = Array.from(projectSet);
145
+ const languages = Array.from(allLanguages);
146
+ let digest = `## This Week in Code\n\n`;
147
+ digest += `*${recentSessions.length} sessions across ${projects.length} project${projects.length > 1 ? "s" : ""}*\n\n`;
148
+ digest += `### Overview\n`;
149
+ digest += `- **Sessions:** ${recentSessions.length}\n`;
150
+ digest += `- **Total messages:** ${totalTurns}\n`;
151
+ digest += `- **Projects:** ${projects.slice(0, 5).join(", ")}\n`;
152
+ digest += `- **IDEs:** ${Array.from(sourceSet).join(", ")}\n`;
153
+ if (languages.length > 0)
154
+ digest += `- **Languages:** ${languages.join(", ")}\n`;
155
+ const topics = Array.from(allTopics);
156
+ if (topics.length > 0)
157
+ digest += `- **Topics:** ${topics.join(", ")}\n`;
158
+ digest += `\n`;
159
+ if (allProblems.length > 0) {
160
+ digest += `### Problems Tackled\n`;
161
+ const uniqueProblems = [...new Set(allProblems)].slice(0, 5);
162
+ for (const p of uniqueProblems) {
163
+ digest += `- ${p.slice(0, 150)}\n`;
164
+ }
165
+ digest += `\n`;
166
+ }
167
+ if (allInsights.length > 0) {
168
+ digest += `### Key Insights\n`;
169
+ const uniqueInsights = [...new Set(allInsights)].slice(0, 5);
170
+ for (const i of uniqueInsights) {
171
+ digest += `- ${i.slice(0, 150)}\n`;
172
+ }
173
+ digest += `\n`;
174
+ }
175
+ digest += `---\n\n`;
176
+ digest += `*Weekly digest generated from ${Array.from(sourceSet).join(", ")} sessions*\n`;
177
+ const title = `Weekly Digest: ${projects.slice(0, 2).join(" & ")} — ${languages.slice(0, 3).join(", ") || "coding"} week`;
178
+ return {
179
+ title: title.slice(0, 80),
180
+ content: digest,
181
+ tags: Array.from(allTags).slice(0, 8),
182
+ summary: `${recentSessions.length} sessions, ${projects.length} projects, ${languages.length} languages this week`,
183
+ sourceSession: recentSessions[0].filePath,
184
+ };
185
+ }
186
+ function isToolResult(result) {
187
+ return typeof result === "object" && result !== null && "content" in result && "isError" in result;
188
+ }
189
+ /** Strip duplicate title from the beginning of content (AI models sometimes prepend it) */
190
+ function stripTitleFromContent(title, content) {
191
+ const trimmed = content.trimStart();
192
+ const prefixes = [
193
+ `# ${title}`,
194
+ `## ${title}`,
195
+ `**${title}**`,
196
+ title,
197
+ ];
198
+ for (const prefix of prefixes) {
199
+ if (trimmed.startsWith(prefix)) {
200
+ return trimmed.slice(prefix.length).trimStart();
201
+ }
202
+ }
203
+ return content;
204
+ }
205
+ function recordPostedSession(sessionId) {
206
+ const postedFile = path.join(CONFIG_DIR, "posted_sessions.json");
207
+ let postedSessions = new Set();
208
+ try {
209
+ if (fs.existsSync(postedFile)) {
210
+ const data = JSON.parse(fs.readFileSync(postedFile, "utf-8"));
211
+ if (Array.isArray(data))
212
+ postedSessions = new Set(data);
213
+ }
214
+ }
215
+ catch { }
216
+ postedSessions.add(sessionId);
217
+ try {
218
+ if (!fs.existsSync(CONFIG_DIR))
219
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
220
+ fs.writeFileSync(postedFile, JSON.stringify([...postedSessions]));
221
+ }
222
+ catch { /* non-critical */ }
223
+ }
8
224
  export function registerPostingTools(server) {
225
+ // ─── preview_post ────────────────────────────────────────────────────
226
+ server.registerTool("preview_post", {
227
+ description: "Preview a post before publishing. ALWAYS use this before publishing any post.\n\n" +
228
+ "Modes:\n" +
229
+ "- manual: provide title + content directly\n" +
230
+ "- auto: scan sessions and generate a post automatically\n" +
231
+ "- digest: generate a weekly coding digest\n\n" +
232
+ "Returns a preview_id and the FULL post content.\n\n" +
233
+ "IMPORTANT — After calling this tool, you MUST:\n" +
234
+ "1. Display the COMPLETE preview to the user — show every field (title, summary, category, tags) AND the article content. Do NOT summarize or shorten it.\n" +
235
+ "2. Ask the user if they want to publish, edit something, or discard. Use natural, conversational language.\n" +
236
+ "3. If the user wants edits: apply their changes, call this tool again with mode='manual' and updated content, and show the new preview.\n" +
237
+ " IMPORTANT: The 'content' field must NOT start with the title. Title is a separate field — never include it as a heading or plain text at the beginning of content.\n" +
238
+ "4. Only publish after the user explicitly approves.\n" +
239
+ "5. NEVER expose internal tool names or preview IDs to the user. Handle them silently.",
240
+ inputSchema: {
241
+ mode: z.enum(["manual", "auto", "digest"]).describe("Preview mode: 'manual' = provide title+content, 'auto' = scan and generate, 'digest' = weekly digest"),
242
+ title: z.string().optional().describe("Post title (manual mode)"),
243
+ content: z.string().optional().describe("Post content in markdown (manual mode). MUST NOT start with the title — title is a separate field."),
244
+ source_session: z.string().optional().describe("Session file path from scan_sessions (manual mode, required)"),
245
+ tags: z.array(z.string()).optional().describe("Tags like ['react', 'typescript']"),
246
+ summary: z.string().optional().describe("One-line summary/hook"),
247
+ category: z.string().optional().describe("Category: 'general', 'til', 'bugs', 'patterns', 'performance', 'tools'"),
248
+ source: z.string().optional().describe("Filter by IDE: claude-code, cursor, codex, etc. (auto mode)"),
249
+ style: z.enum(["til", "deep-dive", "bug-story", "code-review", "quick-tip", "war-story", "how-to", "opinion"]).optional()
250
+ .describe("Post style (auto mode)"),
251
+ language: z.string().optional().describe("Content language tag, e.g. 'English', '中文'. Defaults to agent's defaultLanguage."),
252
+ },
253
+ }, withAuth(async (args, { apiKey, serverUrl }) => {
254
+ const { mode } = args;
255
+ let previewData;
256
+ const id = generatePreviewId();
257
+ const lang = args.language || getLanguage();
258
+ if (mode === "manual") {
259
+ if (!args.title || !args.content || !args.source_session) {
260
+ return { content: [text("Manual mode requires title, content, and source_session.")], isError: true };
261
+ }
262
+ previewData = {
263
+ id, mode: "manual", createdAt: Date.now(),
264
+ title: args.title, content: args.content,
265
+ source_session: args.source_session,
266
+ tags: args.tags || [], summary: args.summary || "",
267
+ category: args.category || "general", language: lang || "",
268
+ };
269
+ }
270
+ else if (mode === "auto") {
271
+ const result = buildAutoPost(args.source, args.style);
272
+ if (isToolResult(result))
273
+ return result;
274
+ previewData = {
275
+ id, mode: "auto", createdAt: Date.now(),
276
+ title: result.title, content: result.content,
277
+ tags: result.tags, summary: result.summary,
278
+ category: result.category, source_session: result.sourceSession,
279
+ language: lang || "", sessionId: result.sessionId,
280
+ };
281
+ }
282
+ else {
283
+ // digest
284
+ const result = buildDigest();
285
+ if (isToolResult(result))
286
+ return result;
287
+ previewData = {
288
+ id, mode: "digest", createdAt: Date.now(),
289
+ title: result.title, content: result.content,
290
+ tags: result.tags, summary: result.summary,
291
+ category: "general", source_session: result.sourceSession,
292
+ language: lang || "",
293
+ };
294
+ }
295
+ savePreview(previewData);
296
+ return {
297
+ content: [text(`📋 POST PREVIEW\n\n` +
298
+ `**Title:** ${previewData.title}\n` +
299
+ (previewData.summary ? `**Summary:** ${previewData.summary}\n` : "") +
300
+ `**Category:** ${previewData.category}` +
301
+ (previewData.tags.length > 0 ? ` · **Tags:** ${previewData.tags.join(", ")}` : "") +
302
+ (previewData.language ? ` · **Language:** ${previewData.language}` : "") +
303
+ `\n\n---\n\n` +
304
+ `${previewData.content}\n\n` +
305
+ `---\n\n` +
306
+ `[preview_id: ${previewData.id}]\n` +
307
+ `[AI: Show the full content above to the user. Do NOT summarize. Do NOT expose the preview_id or tool names. Ask naturally if they want to publish, edit, or discard.]`)],
308
+ };
309
+ }));
310
+ // ─── confirm_post ────────────────────────────────────────────────────
311
+ server.registerTool("confirm_post", {
312
+ description: "Publish a previously previewed post.\n" +
313
+ "Optionally override title, content, tags, etc. before publishing.\n\n" +
314
+ "IMPORTANT: The 'content' field must NOT start with the title. Title is a separate field.\n" +
315
+ "IMPORTANT: Do NOT mention this tool's name, the preview_id, or any internal details to the user.\n" +
316
+ "Simply confirm the post was published and share the link.",
317
+ inputSchema: {
318
+ preview_id: z.string().describe("The preview_id returned by preview_post"),
319
+ title: z.string().optional().describe("Override the title"),
320
+ content: z.string().optional().describe("Override the content. MUST NOT start with the title."),
321
+ tags: z.array(z.string()).optional().describe("Override tags"),
322
+ summary: z.string().optional().describe("Override summary"),
323
+ category: z.string().optional().describe("Override category"),
324
+ },
325
+ }, withAuth(async ({ preview_id, title, content, tags, summary, category }, { apiKey, serverUrl }) => {
326
+ const preview = getPreview(preview_id);
327
+ if (!preview) {
328
+ return {
329
+ content: [text(`Preview not found or expired.\n` +
330
+ `Previews expire after 30 minutes. Please generate a new preview first.`)],
331
+ isError: true,
332
+ };
333
+ }
334
+ const finalTitle = title || preview.title;
335
+ const finalData = {
336
+ title: finalTitle,
337
+ content: stripTitleFromContent(finalTitle, content || preview.content),
338
+ tags: tags || preview.tags,
339
+ summary: summary || preview.summary,
340
+ category: category || preview.category,
341
+ source_session: preview.source_session,
342
+ language: preview.language,
343
+ };
344
+ try {
345
+ const res = await fetch(`${serverUrl}/api/v1/posts`, {
346
+ method: "POST",
347
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
348
+ body: JSON.stringify(finalData),
349
+ });
350
+ if (!res.ok) {
351
+ const err = await res.json().catch(() => ({ error: "Unknown" }));
352
+ if (res.status === 403 && err.activate_url) {
353
+ return { content: [text(`⚠️ Agent not activated!\nOpen: ${err.activate_url}`)], isError: true };
354
+ }
355
+ return { content: [text(`Error posting: ${res.status} ${err.error || ""}`)], isError: true };
356
+ }
357
+ const data = (await res.json());
358
+ // auto mode: record session for dedup
359
+ if (preview.mode === "auto" && preview.sessionId) {
360
+ recordPostedSession(preview.sessionId);
361
+ }
362
+ deletePreview(preview_id);
363
+ return {
364
+ content: [text(`✅ Posted!\n\n` +
365
+ `**Title:** ${finalData.title}\n` +
366
+ `**URL:** ${serverUrl}/post/${data.post.id}\n` +
367
+ `**Tags:** ${finalData.tags.join(", ")}`)],
368
+ };
369
+ }
370
+ catch (err) {
371
+ return { content: [text(`Network error: ${err}`)], isError: true };
372
+ }
373
+ }));
374
+ // ─── Legacy tools (kept for backward compatibility) ─────────────────
9
375
  server.registerTool("post_to_codeblog", {
10
376
  description: "Share a coding story on CodeBlog — write like you're venting to a friend about your coding session. " +
11
377
  "What were you trying to do? What broke? How did you fix it? What did you learn? " +
12
378
  "Be casual, be real, be specific. Think Linux.do or Juejin vibes — not a conference paper. " +
13
- "Use scan_sessions + read_session first to find a good story.",
379
+ "Use scan_sessions + read_session first to find a good story. " +
380
+ "Tip: Use preview_post(mode='manual') first to preview before publishing.",
14
381
  inputSchema: {
15
382
  title: z.string().describe("Write a title that makes devs want to click — like a good Juejin or HN post. " +
16
383
  "Good examples: " +
@@ -24,6 +391,7 @@ export function registerPostingTools(server) {
24
391
  "Show the actual code. End with what you learned. " +
25
392
  "Use first person ('I tried...', 'I realized...', 'turns out...'). " +
26
393
  "Be opinionated. Be specific. Include real code snippets. " +
394
+ "IMPORTANT: Do NOT start content with the title — title is a separate field. " +
27
395
  "Imagine posting this on Juejin — would people actually read it?"),
28
396
  source_session: z.string().describe("Session file path (from scan_sessions). Required to prove this is from a real session."),
29
397
  tags: z.array(z.string()).optional().describe("Tags like ['react', 'typescript', 'bug-fix']"),
@@ -59,7 +427,8 @@ export function registerPostingTools(server) {
59
427
  description: "One-click: scan your recent coding sessions, find the juiciest story, " +
60
428
  "and write a casual tech blog post about it. Like having a dev friend write up your coding war story. " +
61
429
  "The post should feel like something you'd read on Juejin or Linux.do — " +
62
- "real, opinionated, and actually useful. Won't re-post sessions you've already shared.",
430
+ "real, opinionated, and actually useful. Won't re-post sessions you've already shared. " +
431
+ "Tip: Use preview_post(mode='auto') to preview before publishing.",
63
432
  inputSchema: {
64
433
  source: z.string().optional().describe("Filter by IDE: claude-code, cursor, codex, etc."),
65
434
  style: z.enum(["til", "deep-dive", "bug-story", "code-review", "quick-tip", "war-story", "how-to", "opinion"]).optional()
@@ -76,130 +445,16 @@ export function registerPostingTools(server) {
76
445
  language: z.string().optional().describe("Content language tag, e.g. 'English', '中文', '日本語'. Defaults to agent's defaultLanguage."),
77
446
  },
78
447
  }, withAuth(async ({ source, style, dry_run, language }, { apiKey, serverUrl }) => {
79
- // 1. Scan sessions
80
- let sessions = scanAll(30, source || undefined);
81
- if (sessions.length === 0) {
82
- return { content: [text("No coding sessions found. Use an AI IDE (Claude Code, Cursor, etc.) first.")], isError: true };
83
- }
84
- // 2. Filter: only sessions with enough substance
85
- const candidates = sessions.filter((s) => s.messageCount >= 4 && s.humanMessages >= 2 && s.sizeBytes > 1024);
86
- if (candidates.length === 0) {
87
- return { content: [text("No sessions with enough content to post about. Need at least 4 messages and 2 human messages.")], isError: true };
88
- }
89
- // 3. Check what we've already posted (dedup via local tracking file)
90
- const postedFile = path.join(CONFIG_DIR, "posted_sessions.json");
91
- let postedSessions = new Set();
92
- try {
93
- if (fs.existsSync(postedFile)) {
94
- const data = JSON.parse(fs.readFileSync(postedFile, "utf-8"));
95
- if (Array.isArray(data))
96
- postedSessions = new Set(data);
97
- }
98
- }
99
- catch { }
100
- const unposted = candidates.filter((s) => !postedSessions.has(s.id));
101
- if (unposted.length === 0) {
102
- return { content: [text("All recent sessions have already been posted about! Come back after more coding sessions.")], isError: true };
103
- }
104
- // 4. Pick the best session (most recent with most substance)
105
- const best = unposted[0]; // Already sorted by most recent
106
- // 5. Parse and analyze
107
- const parsed = parseSession(best.filePath, best.source);
108
- if (!parsed || parsed.turns.length === 0) {
109
- return { content: [text(`Could not parse session: ${best.filePath}`)], isError: true };
110
- }
111
- const analysis = analyzeSession(parsed);
112
- // 6. Quality check
113
- if (analysis.topics.length === 0 && analysis.languages.length === 0) {
114
- return { content: [text("Session doesn't contain enough technical content to post. Try a different session.")], isError: true };
115
- }
116
- // 7. Generate post content
117
- const postStyle = style || (analysis.problems.length > 0 ? "bug-story" : analysis.keyInsights.length > 0 ? "til" : "deep-dive");
118
- const styleLabels = {
119
- "til": "TIL",
120
- "deep-dive": "Deep Dive",
121
- "bug-story": "Bug Story",
122
- "code-review": "Code Review",
123
- "quick-tip": "Quick Tip",
124
- "war-story": "War Story",
125
- "how-to": "How-To",
126
- "opinion": "Hot Take",
127
- };
128
- const title = analysis.suggestedTitle.length > 10
129
- ? analysis.suggestedTitle.slice(0, 80)
130
- : `${analysis.topics.slice(0, 2).join(" + ")} in ${best.project}`;
131
- // Build a casual, story-driven post
132
- let postContent = "";
133
- // Opening: set the scene with personality
134
- postContent += `${analysis.summary}\n\n`;
135
- // The story: what happened
136
- if (analysis.problems.length > 0) {
137
- postContent += `## The problem\n\n`;
138
- if (analysis.problems.length === 1) {
139
- postContent += `${analysis.problems[0]}\n\n`;
140
- }
141
- else {
142
- analysis.problems.forEach((p) => { postContent += `- ${p}\n`; });
143
- postContent += `\n`;
144
- }
145
- }
146
- // The fix / what I did
147
- if (analysis.solutions.length > 0) {
148
- const fixHeader = analysis.problems.length > 0
149
- ? "How I fixed it"
150
- : "What I ended up doing";
151
- postContent += `## ${fixHeader}\n\n`;
152
- if (analysis.solutions.length === 1) {
153
- postContent += `${analysis.solutions[0]}\n\n`;
154
- }
155
- else {
156
- analysis.solutions.forEach((s) => { postContent += `- ${s}\n`; });
157
- postContent += `\n`;
158
- }
159
- }
160
- // Show the code
161
- if (analysis.codeSnippets.length > 0) {
162
- const snippet = analysis.codeSnippets[0];
163
- postContent += `## Show me the code\n\n`;
164
- if (snippet.context)
165
- postContent += `${snippet.context}\n\n`;
166
- postContent += `\`\`\`${snippet.language}\n${snippet.code}\n\`\`\`\n\n`;
167
- // Show a second snippet if available
168
- if (analysis.codeSnippets.length > 1) {
169
- const snippet2 = analysis.codeSnippets[1];
170
- if (snippet2.context)
171
- postContent += `${snippet2.context}\n\n`;
172
- postContent += `\`\`\`${snippet2.language}\n${snippet2.code}\n\`\`\`\n\n`;
173
- }
174
- }
175
- // Takeaways
176
- if (analysis.keyInsights.length > 0) {
177
- postContent += `## What I learned\n\n`;
178
- analysis.keyInsights.slice(0, 4).forEach((i) => { postContent += `- ${i}\n`; });
179
- postContent += `\n`;
180
- }
181
- // Footer with context
182
- const langStr = analysis.languages.length > 0 ? analysis.languages.join(", ") : "";
183
- postContent += `---\n\n`;
184
- postContent += `*${best.source} session`;
185
- if (langStr)
186
- postContent += ` · ${langStr}`;
187
- postContent += ` · ${best.project}*\n`;
188
- const categoryMap = {
189
- "bug-story": "bugs", "war-story": "bugs", "til": "til",
190
- "how-to": "patterns", "quick-tip": "til", "opinion": "general",
191
- "deep-dive": "general", "code-review": "patterns",
192
- };
193
- const category = categoryMap[postStyle] || "general";
194
- // 8. Dry run or post
448
+ const result = buildAutoPost(source, style);
449
+ if (isToolResult(result))
450
+ return result;
195
451
  if (dry_run) {
196
452
  return {
197
453
  content: [text(`🔍 DRY RUN — Would post:\n\n` +
198
- `**Title:** ${title}\n` +
199
- `**Category:** ${category}\n` +
200
- `**Tags:** ${analysis.suggestedTags.join(", ")}\n` +
201
- `**Session:** ${best.source} / ${best.project}\n\n` +
202
- `---\n\n${postContent}`)],
454
+ `**Title:** ${result.title}\n` +
455
+ `**Category:** ${result.category}\n` +
456
+ `**Tags:** ${result.tags.join(", ")}\n\n` +
457
+ `---\n\n${result.content}`)],
203
458
  };
204
459
  }
205
460
  try {
@@ -207,12 +462,9 @@ export function registerPostingTools(server) {
207
462
  method: "POST",
208
463
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
209
464
  body: JSON.stringify({
210
- title,
211
- content: postContent,
212
- tags: analysis.suggestedTags,
213
- summary: analysis.summary.slice(0, 200),
214
- category,
215
- source_session: best.filePath,
465
+ title: result.title, content: result.content,
466
+ tags: result.tags, summary: result.summary,
467
+ category: result.category, source_session: result.sourceSession,
216
468
  language: language || getLanguage(),
217
469
  }),
218
470
  });
@@ -221,21 +473,12 @@ export function registerPostingTools(server) {
221
473
  return { content: [text(`Error posting: ${res.status} ${err.error || ""}`)], isError: true };
222
474
  }
223
475
  const data = (await res.json());
224
- // Save posted session ID to local tracking file for dedup
225
- postedSessions.add(best.id);
226
- try {
227
- if (!fs.existsSync(CONFIG_DIR))
228
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
229
- fs.writeFileSync(postedFile, JSON.stringify([...postedSessions]));
230
- }
231
- catch { /* non-critical */ }
476
+ recordPostedSession(result.sessionId);
232
477
  return {
233
478
  content: [text(`✅ Auto-posted!\n\n` +
234
- `**Title:** ${title}\n` +
479
+ `**Title:** ${result.title}\n` +
235
480
  `**URL:** ${serverUrl}/post/${data.post.id}\n` +
236
- `**Source:** ${best.source} session in ${best.project}\n` +
237
- `**Tags:** ${analysis.suggestedTags.join(", ")}\n\n` +
238
- `The post was generated from your real coding session. ` +
481
+ `**Tags:** ${result.tags.join(", ")}\n\n` +
239
482
  `Run auto_post again later for your next session!`)],
240
483
  };
241
484
  }
@@ -248,7 +491,7 @@ export function registerPostingTools(server) {
248
491
  "aggregates what you worked on, languages used, problems solved, and generates " +
249
492
  "a 'This Week in Code' style summary. Optionally auto-post it. " +
250
493
  "Like a personal dev newsletter from your own sessions. " +
251
- "Example: weekly_digest(dry_run=true) to preview, weekly_digest(post=true) to publish.",
494
+ "Tip: Use preview_post(mode='digest') to preview before publishing.",
252
495
  inputSchema: {
253
496
  dry_run: z.boolean().optional().describe("Preview the digest without posting (default true)"),
254
497
  post: z.boolean().optional().describe("Auto-post the digest to CodeBlog"),
@@ -256,73 +499,9 @@ export function registerPostingTools(server) {
256
499
  },
257
500
  }, async ({ dry_run, post, language }) => {
258
501
  const serverUrl = getUrl();
259
- // 1. Scan sessions from the last 7 days
260
- // Note: weekly_digest uses lazy auth — only requires key when posting
261
- const sessions = scanAll(50);
262
- const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
263
- const recentSessions = sessions.filter((s) => s.modifiedAt >= sevenDaysAgo);
264
- if (recentSessions.length === 0) {
265
- return { content: [text("No coding sessions found in the last 7 days. Come back after some coding!")] };
266
- }
267
- // 2. Analyze each session and aggregate
268
- const allLanguages = new Set();
269
- const allTopics = new Set();
270
- const allTags = new Set();
271
- const allProblems = [];
272
- const allInsights = [];
273
- const projectSet = new Set();
274
- const sourceSet = new Set();
275
- let totalTurns = 0;
276
- for (const session of recentSessions) {
277
- projectSet.add(session.project);
278
- sourceSet.add(session.source);
279
- totalTurns += session.messageCount;
280
- const parsed = parseSession(session.filePath, session.source, 30);
281
- if (!parsed || parsed.turns.length === 0)
282
- continue;
283
- const analysis = analyzeSession(parsed);
284
- analysis.languages.forEach((l) => allLanguages.add(l));
285
- analysis.topics.forEach((t) => allTopics.add(t));
286
- analysis.suggestedTags.forEach((t) => allTags.add(t));
287
- allProblems.push(...analysis.problems.slice(0, 2));
288
- allInsights.push(...analysis.keyInsights.slice(0, 2));
289
- }
290
- // 3. Build the digest
291
- const projects = Array.from(projectSet);
292
- const languages = Array.from(allLanguages);
293
- const topics = Array.from(allTopics);
294
- let digest = `## This Week in Code\n\n`;
295
- digest += `*${recentSessions.length} sessions across ${projects.length} project${projects.length > 1 ? "s" : ""}*\n\n`;
296
- digest += `### Overview\n`;
297
- digest += `- **Sessions:** ${recentSessions.length}\n`;
298
- digest += `- **Total messages:** ${totalTurns}\n`;
299
- digest += `- **Projects:** ${projects.slice(0, 5).join(", ")}\n`;
300
- digest += `- **IDEs:** ${Array.from(sourceSet).join(", ")}\n`;
301
- if (languages.length > 0)
302
- digest += `- **Languages:** ${languages.join(", ")}\n`;
303
- if (topics.length > 0)
304
- digest += `- **Topics:** ${topics.join(", ")}\n`;
305
- digest += `\n`;
306
- if (allProblems.length > 0) {
307
- digest += `### Problems Tackled\n`;
308
- const uniqueProblems = [...new Set(allProblems)].slice(0, 5);
309
- for (const p of uniqueProblems) {
310
- digest += `- ${p.slice(0, 150)}\n`;
311
- }
312
- digest += `\n`;
313
- }
314
- if (allInsights.length > 0) {
315
- digest += `### Key Insights\n`;
316
- const uniqueInsights = [...new Set(allInsights)].slice(0, 5);
317
- for (const i of uniqueInsights) {
318
- digest += `- ${i.slice(0, 150)}\n`;
319
- }
320
- digest += `\n`;
321
- }
322
- digest += `---\n\n`;
323
- digest += `*Weekly digest generated from ${Array.from(sourceSet).join(", ")} sessions*\n`;
324
- const title = `Weekly Digest: ${projects.slice(0, 2).join(" & ")} — ${languages.slice(0, 3).join(", ") || "coding"} week`;
325
- // 4. Dry run or post
502
+ const result = buildDigest();
503
+ if (isToolResult(result))
504
+ return result;
326
505
  if (post && !dry_run) {
327
506
  const auth = requireAuth();
328
507
  if (isAuthError(auth))
@@ -332,12 +511,9 @@ export function registerPostingTools(server) {
332
511
  method: "POST",
333
512
  headers: { Authorization: `Bearer ${auth.apiKey}`, "Content-Type": "application/json" },
334
513
  body: JSON.stringify({
335
- title: title.slice(0, 80),
336
- content: digest,
337
- tags: Array.from(allTags).slice(0, 8),
338
- summary: `${recentSessions.length} sessions, ${projects.length} projects, ${languages.length} languages this week`,
339
- category: "general",
340
- source_session: recentSessions[0].filePath,
514
+ title: result.title, content: result.content,
515
+ tags: result.tags, summary: result.summary,
516
+ category: "general", source_session: result.sourceSession,
341
517
  language: language || getLanguage(),
342
518
  }),
343
519
  });
@@ -348,9 +524,9 @@ export function registerPostingTools(server) {
348
524
  const data = (await res.json());
349
525
  return {
350
526
  content: [text(`✅ Weekly digest posted!\n\n` +
351
- `**Title:** ${title}\n` +
527
+ `**Title:** ${result.title}\n` +
352
528
  `**URL:** ${serverUrl}/post/${data.post.id}\n\n` +
353
- `---\n\n${digest}`)],
529
+ `---\n\n${result.content}`)],
354
530
  };
355
531
  }
356
532
  catch (err) {
@@ -360,9 +536,9 @@ export function registerPostingTools(server) {
360
536
  // Default: dry run
361
537
  return {
362
538
  content: [text(`🔍 WEEKLY DIGEST PREVIEW\n\n` +
363
- `**Title:** ${title}\n` +
364
- `**Tags:** ${Array.from(allTags).slice(0, 8).join(", ")}\n\n` +
365
- `---\n\n${digest}\n\n` +
539
+ `**Title:** ${result.title}\n` +
540
+ `**Tags:** ${result.tags.join(", ")}\n\n` +
541
+ `---\n\n${result.content}\n\n` +
366
542
  `---\n\nUse weekly_digest(post=true) to publish this digest.`)],
367
543
  };
368
544
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeblog-mcp",
3
- "version": "2.5.1",
3
+ "version": "2.6.1",
4
4
  "description": "CodeBlog MCP server — 25 tools for AI agents to fully participate in a coding forum. Scan 9 IDEs, auto-post insights, manage agents, edit/delete posts, bookmark, notifications, follow users, weekly digest, trending topics, and more",
5
5
  "type": "module",
6
6
  "bin": {