@vncsleal/quillby 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/quillby-mcp +12 -0
  4. package/config/context.json +1 -0
  5. package/config/memory.json +1 -0
  6. package/config/rss_sources.txt +2 -0
  7. package/dist/agent.d.ts +21 -0
  8. package/dist/agent.js +61 -0
  9. package/dist/agent.js.map +1 -0
  10. package/dist/agents/compose.d.ts +2 -0
  11. package/dist/agents/compose.js +40 -0
  12. package/dist/agents/compose.js.map +1 -0
  13. package/dist/agents/discover.d.ts +15 -0
  14. package/dist/agents/discover.js +39 -0
  15. package/dist/agents/discover.js.map +1 -0
  16. package/dist/agents/harvest.d.ts +31 -0
  17. package/dist/agents/harvest.js +100 -0
  18. package/dist/agents/harvest.js.map +1 -0
  19. package/dist/agents/onboard.d.ts +14 -0
  20. package/dist/agents/onboard.js +88 -0
  21. package/dist/agents/onboard.js.map +1 -0
  22. package/dist/agents/seeds.d.ts +34 -0
  23. package/dist/agents/seeds.js +64 -0
  24. package/dist/agents/seeds.js.map +1 -0
  25. package/dist/cli.d.ts +44 -0
  26. package/dist/cli.js +233 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/config.d.ts +22 -0
  29. package/dist/config.js +44 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/extractors/content.d.ts +13 -0
  32. package/dist/extractors/content.js +93 -0
  33. package/dist/extractors/content.js.map +1 -0
  34. package/dist/extractors/reddit.d.ts +7 -0
  35. package/dist/extractors/reddit.js +69 -0
  36. package/dist/extractors/reddit.js.map +1 -0
  37. package/dist/extractors/rss.d.ts +4 -0
  38. package/dist/extractors/rss.js +58 -0
  39. package/dist/extractors/rss.js.map +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +139 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/init.d.ts +1 -0
  44. package/dist/init.js +192 -0
  45. package/dist/init.js.map +1 -0
  46. package/dist/llm.d.ts +2 -0
  47. package/dist/llm.js +11 -0
  48. package/dist/llm.js.map +1 -0
  49. package/dist/mcp/server.d.ts +1 -0
  50. package/dist/mcp/server.js +1278 -0
  51. package/dist/mcp/server.js.map +1 -0
  52. package/dist/output/feedback.d.ts +51 -0
  53. package/dist/output/feedback.js +78 -0
  54. package/dist/output/feedback.js.map +1 -0
  55. package/dist/output/structures.d.ts +5 -0
  56. package/dist/output/structures.js +121 -0
  57. package/dist/output/structures.js.map +1 -0
  58. package/dist/types.d.ts +305 -0
  59. package/dist/types.js +74 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +63 -0
@@ -0,0 +1,1278 @@
1
+ import "dotenv/config";
2
+ import * as http from "node:http";
3
+ import { randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
+ import { UserContextSchema, CardInputSchema } from "../types.js";
9
+ import { loadContext, saveContext, contextExists, contextToPromptText, ONBOARDING_PROMPT, loadMemory, appendVoiceExample, } from "../agents/onboard.js";
10
+ import { loadSources, appendSources } from "../agents/discover.js";
11
+ import { fetchArticles, preScoreArticles } from "../agents/harvest.js";
12
+ import { getGoogleNewsFeeds, getMediumTagFeeds, getFeedlyFeeds } from "../agents/seeds.js";
13
+ import { PLATFORM_GUIDES } from "../agents/compose.js";
14
+ import { enrichArticle } from "../extractors/content.js";
15
+ import { saveSeenUrls } from "../extractors/rss.js";
16
+ import { loadLatestHarvest, latestHarvestExists, saveHarvestOutput, saveDraft, } from "../output/structures.js";
17
+ const server = new Server({ name: "quillby-mcp", version: "0.3.0" }, { capabilities: { tools: {}, resources: {}, prompts: {}, logging: {} } });
18
+ function log(message) {
19
+ server.sendLoggingMessage({ level: "info", data: message }).catch(() => { });
20
+ }
21
+ /**
22
+ * Ask the host model to run inference via MCP Sampling.
23
+ * Returns null if the host does not support Sampling — callers degrade gracefully.
24
+ */
25
+ async function sample(prompt, maxTokens = 4096) {
26
+ const caps = server.getClientCapabilities();
27
+ if (!caps?.sampling)
28
+ return null;
29
+ try {
30
+ const result = await server.createMessage({
31
+ messages: [{ role: "user", content: { type: "text", text: prompt } }],
32
+ maxTokens,
33
+ });
34
+ if (result.content.type === "text")
35
+ return result.content.text;
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ const TOOLS = [
43
+ // ── Onboarding ────────────────────────────────────────────────────────────
44
+ {
45
+ name: "quillby_onboard",
46
+ description: "Interactive onboarding via MCP Elicitation. Asks 3 inline questions and saves your content creator profile. Falls back to text instructions if the client does not support Elicitation.",
47
+ annotations: { idempotentHint: true },
48
+ outputSchema: { type: "object" },
49
+ inputSchema: { type: "object", properties: {} },
50
+ },
51
+ // ── Profile ───────────────────────────────────────────────────────────────
52
+ {
53
+ name: "quillby_set_context",
54
+ description: "Save the user content creator profile after onboarding.",
55
+ annotations: { destructiveHint: false, idempotentHint: true },
56
+ outputSchema: { type: "object" },
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ context: {
61
+ type: "object",
62
+ properties: {
63
+ name: { type: "string" },
64
+ role: { type: "string" },
65
+ industry: { type: "string" },
66
+ topics: { type: "array", items: { type: "string" } },
67
+ voice: { type: "string" },
68
+ audienceDescription: { type: "string" },
69
+ contentGoals: { type: "array", items: { type: "string" } },
70
+ excludeTopics: { type: "array", items: { type: "string" } },
71
+ platforms: { type: "array", items: { type: "string" } },
72
+ },
73
+ required: ["role", "industry", "topics", "voice", "audienceDescription", "contentGoals", "platforms"],
74
+ },
75
+ },
76
+ required: ["context"],
77
+ },
78
+ },
79
+ {
80
+ name: "quillby_get_context",
81
+ description: "Load the saved user profile.",
82
+ annotations: { readOnlyHint: true, idempotentHint: true },
83
+ outputSchema: { type: "object" },
84
+ inputSchema: { type: "object", properties: {} },
85
+ },
86
+ // ── Feeds ─────────────────────────────────────────────────────────────────
87
+ {
88
+ name: "quillby_discover_feeds",
89
+ description: "Discover and save content sources for the user's topics. Adds: Google News RSS (real-time news, any language), Medium tag feeds (professional articles on any industry), Feedly curated publications, and Reddit communities (reddit://r/<subreddit>) via Sampling. Works for any niche: healthcare, law, fashion, construction, farming, finance, etc.",
90
+ annotations: { idempotentHint: true },
91
+ outputSchema: { type: "object" },
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ topics: {
96
+ type: "array",
97
+ items: { type: "string" },
98
+ description: "Override topics. Defaults to saved user context topics.",
99
+ },
100
+ locale: {
101
+ type: "string",
102
+ description: "BCP-47 language tag for Google News, e.g. \"en-US\", \"pt-BR\", \"fr-FR\". Defaults to en-US.",
103
+ },
104
+ country: {
105
+ type: "string",
106
+ description: "ISO 3166-1 country code for Google News, e.g. \"US\", \"BR\", \"FR\". Defaults to US.",
107
+ },
108
+ },
109
+ },
110
+ },
111
+ {
112
+ name: "quillby_add_feeds",
113
+ description: "Add content sources manually. Accepts: standard RSS/Atom URLs, Medium tag feeds (https://medium.com/feed/tag/<topic>), Google News RSS URLs, and Reddit communities (reddit://r/<subreddit> or reddit://r/<subreddit>/top). Deduplicates automatically.",
114
+ annotations: { idempotentHint: true },
115
+ outputSchema: { type: "object" },
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ urls: { type: "array", items: { type: "string" }, description: "Source URLs: RSS/Atom URLs, medium.com/feed/tag/*, reddit://r/name" },
120
+ },
121
+ required: ["urls"],
122
+ },
123
+ },
124
+ {
125
+ name: "quillby_list_feeds",
126
+ description: "List all configured RSS feed URLs.",
127
+ annotations: { readOnlyHint: true, idempotentHint: true },
128
+ outputSchema: { type: "object" },
129
+ inputSchema: { type: "object", properties: {} },
130
+ },
131
+ // ── Fetch & Research ──────────────────────────────────────────────────────
132
+ {
133
+ name: "quillby_fetch_articles",
134
+ description: "Fetch articles from all configured sources: RSS feeds (Google News, Medium, Feedly, any Atom/RSS URL) and Reddit communities (reddit://r/). Use slim=true for a fast headline index, then quillby_read_article for depth. Articles are pre-sorted by keyword relevance against saved user topics.",
135
+ annotations: { readOnlyHint: false },
136
+ outputSchema: { type: "object" },
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ sources: { type: "array", items: { type: "string" }, description: "Override RSS URLs. Defaults to all configured sources." },
141
+ slim: { type: "boolean", description: "Return only title/source/link/snippet — no content fetching. Default: false." },
142
+ },
143
+ },
144
+ },
145
+ {
146
+ name: "quillby_read_article",
147
+ description: "Fetch full text for a single article URL using Mozilla Readability. Use after quillby_fetch_articles (slim=true).",
148
+ annotations: { readOnlyHint: true, idempotentHint: true },
149
+ outputSchema: { type: "object" },
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ url: { type: "string", description: "Article URL to fetch" },
154
+ title: { type: "string", description: "Article title (improves extraction)" },
155
+ },
156
+ required: ["url"],
157
+ },
158
+ },
159
+ // ── Analyze (Sampling-powered) ────────────────────────────────────────────
160
+ {
161
+ name: "quillby_analyze_articles",
162
+ description: "Full pipeline in one call: fetches feeds, pre-filters by keyword relevance, enriches top articles with Readability, then uses MCP Sampling to score and structure them into cards — all via the host model, no extra API key needed. Falls back to returning pre-scored headlines if Sampling is unavailable.",
163
+ annotations: { readOnlyHint: false },
164
+ outputSchema: { type: "object" },
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ articleIds: {
169
+ type: "array",
170
+ items: { type: "string" },
171
+ description: "Limit analysis to specific article links. Omit to analyze all fetched articles.",
172
+ },
173
+ topN: {
174
+ type: "number",
175
+ description: "Analyze only the top N pre-scored articles. Default: 15.",
176
+ },
177
+ },
178
+ },
179
+ },
180
+ // ── Daily Brief (two-pass Sampling pipeline) ──────────────────────────────
181
+ {
182
+ name: "quillby_daily_brief",
183
+ description: "The daily entry point. Two-pass pipeline: fetch headlines slim → Sampling semantically scores them against your profile → deep-read only the top picks → Sampling generates full cards. One call, full brief. Returns a ranked content brief with angles and hooks ready. Requires Sampling.",
184
+ annotations: { readOnlyHint: false },
185
+ outputSchema: { type: "object" },
186
+ inputSchema: {
187
+ type: "object",
188
+ properties: {
189
+ topN: {
190
+ type: "number",
191
+ description: "How many top-scored articles to deep-read and card. Default: 10.",
192
+ },
193
+ },
194
+ },
195
+ },
196
+ // ── Cards ─────────────────────────────────────────────────────────────────
197
+ {
198
+ name: "quillby_save_cards",
199
+ description: "Save analyzed structure cards. Quillby persists them.",
200
+ annotations: { destructiveHint: false },
201
+ outputSchema: { type: "object" },
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {
205
+ cards: {
206
+ type: "array",
207
+ items: {
208
+ type: "object",
209
+ properties: {
210
+ title: { type: "string" },
211
+ source: { type: "string" },
212
+ link: { type: "string" },
213
+ thesis: { type: "string" },
214
+ relevanceScore: { type: "number" },
215
+ relevanceReason: { type: "string" },
216
+ keyInsights: { type: "array", items: { type: "string" } },
217
+ insightOptions: { type: "array", items: { type: "string" } },
218
+ takeOptions: { type: "array", items: { type: "string" } },
219
+ angleOptions: { type: "array", items: { type: "string" } },
220
+ hookOptions: { type: "array", items: { type: "string" } },
221
+ wireframeOptions: { type: "array", items: { type: "string" } },
222
+ trendTags: { type: "array", items: { type: "string" } },
223
+ transposabilityHint: { type: "string" },
224
+ },
225
+ required: ["title", "source", "link", "thesis"],
226
+ },
227
+ },
228
+ },
229
+ required: ["cards"],
230
+ },
231
+ },
232
+ {
233
+ name: "quillby_list_cards",
234
+ description: "List saved structure cards from the latest harvest.",
235
+ annotations: { readOnlyHint: true, idempotentHint: true },
236
+ outputSchema: { type: "object" },
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ limit: { type: "number", description: "Max cards to return." },
241
+ minScore: { type: "number", description: "Filter cards at or above this relevance score (0–10)." },
242
+ },
243
+ },
244
+ },
245
+ {
246
+ name: "quillby_get_card",
247
+ description: "Get full details of a structure card by ID.",
248
+ annotations: { readOnlyHint: true, idempotentHint: true },
249
+ outputSchema: { type: "object" },
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: { cardId: { type: "number" } },
253
+ required: ["cardId"],
254
+ },
255
+ },
256
+ // ── Drafts ────────────────────────────────────────────────────────────────
257
+ {
258
+ name: "quillby_save_draft",
259
+ description: "Save a draft post to disk.",
260
+ annotations: { destructiveHint: false },
261
+ outputSchema: { type: "object" },
262
+ inputSchema: {
263
+ type: "object",
264
+ properties: {
265
+ content: { type: "string" },
266
+ platform: { type: "string", description: "linkedin, x, instagram, threads, blog, newsletter, medium" },
267
+ cardId: { type: "number" },
268
+ addToVoiceExamples: { type: "boolean", description: "If true, saves this draft as a voice example in memory." },
269
+ },
270
+ required: ["content", "platform"],
271
+ },
272
+ },
273
+ // ── Generate (Sampling-powered) ───────────────────────────────────────────
274
+ {
275
+ name: "quillby_generate_post",
276
+ description: "Generate a finished post via MCP Sampling and save it as a draft. Loads the card, user profile, platform guide, and voice examples — writes the post, saves it. One call: write + save. Requires Sampling.",
277
+ annotations: { destructiveHint: false },
278
+ outputSchema: { type: "object" },
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ cardId: { type: "number", description: "Structure card ID to base the post on." },
283
+ platform: { type: "string", description: "linkedin, x, instagram, threads, blog, newsletter, medium" },
284
+ angle: { type: "string", description: "Specific angle or take to use. If omitted, uses the card's top angle option." },
285
+ },
286
+ required: ["cardId", "platform"],
287
+ },
288
+ },
289
+ // ── Memory ────────────────────────────────────────────────────────────────
290
+ {
291
+ name: "quillby_remember",
292
+ description: "Add one or more approved posts to your voice memory. These accumulate in memory.json and are used to guide voice in every post Quillby generates. Up to 10 most recent examples are kept.",
293
+ annotations: { destructiveHint: false },
294
+ outputSchema: { type: "object" },
295
+ inputSchema: {
296
+ type: "object",
297
+ properties: {
298
+ voiceExamples: {
299
+ type: "array",
300
+ items: { type: "string" },
301
+ description: "Post text(s) to add as voice examples.",
302
+ },
303
+ },
304
+ required: ["voiceExamples"],
305
+ },
306
+ },
307
+ ];
308
+ const RESOURCES = [
309
+ {
310
+ uri: "quillby://context",
311
+ name: "User Content Profile",
312
+ description: "The user content creator profile: role, industry, topics, voice, audience, goals, platforms.",
313
+ mimeType: "application/json",
314
+ },
315
+ {
316
+ uri: "quillby://memory",
317
+ name: "User Memory",
318
+ description: "Accumulated voice examples and other memory that grows over sessions.",
319
+ mimeType: "application/json",
320
+ },
321
+ {
322
+ uri: "quillby://harvest/latest",
323
+ name: "Latest Harvest Cards",
324
+ description: "Structure cards from the most recent fetch+analysis session.",
325
+ mimeType: "application/json",
326
+ },
327
+ {
328
+ uri: "quillby://feeds",
329
+ name: "RSS Feed Sources",
330
+ description: "All configured RSS feed URLs.",
331
+ mimeType: "text/plain",
332
+ },
333
+ ];
334
+ const PROMPTS = [
335
+ {
336
+ name: "quillby_onboarding",
337
+ description: "Guide the user through initial Quillby setup to collect their content creator profile.",
338
+ },
339
+ {
340
+ name: "quillby_workflow",
341
+ description: "Full Quillby workflow: onboard, discover feeds, fetch, analyze, generate posts.",
342
+ },
343
+ ];
344
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
345
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
346
+ const { name, arguments: args = {} } = req.params;
347
+ try {
348
+ switch (name) {
349
+ case "quillby_onboard": {
350
+ const caps = server.getClientCapabilities();
351
+ if (!caps?.elicitation?.form) {
352
+ // Client doesn't support form elicitation — return the static onboarding prompt
353
+ return {
354
+ content: [{ type: "text", text: ONBOARDING_PROMPT }],
355
+ structuredContent: { elicitationAvailable: false, message: ONBOARDING_PROMPT },
356
+ };
357
+ }
358
+ // Step 1 — Identity
359
+ const s1 = await server.elicitInput({
360
+ message: "Let's set up your Quillby profile. Step 1 of 3: who are you?",
361
+ requestedSchema: {
362
+ type: "object",
363
+ properties: {
364
+ name: { type: "string", title: "Your name", description: "Optional — used to personalize prompts" },
365
+ role: { type: "string", title: "Your role", description: "e.g. founder, marketer, software engineer, researcher" },
366
+ industry: { type: "string", title: "Industry or niche", description: "e.g. SaaS, healthcare, fintech, creator economy" },
367
+ },
368
+ required: ["role", "industry"],
369
+ },
370
+ });
371
+ if (s1.action !== "accept" || !s1.content) {
372
+ return {
373
+ content: [{ type: "text", text: "Onboarding cancelled." }],
374
+ structuredContent: { cancelled: true, message: "Onboarding cancelled." },
375
+ };
376
+ }
377
+ // Step 2 — Topics & audience
378
+ const s2 = await server.elicitInput({
379
+ message: "Step 2 of 3: what do you write about, and who reads it?",
380
+ requestedSchema: {
381
+ type: "object",
382
+ properties: {
383
+ topics: { type: "string", title: "Topics to cover", description: "Comma-separated: e.g. AI, developer tools, startup fundraising" },
384
+ audienceDescription: { type: "string", title: "Your audience", description: "e.g. senior engineers at B2B SaaS companies" },
385
+ contentGoals: { type: "string", title: "Content goals", description: "Comma-separated: e.g. build authority, grow newsletter, drive inbound leads" },
386
+ },
387
+ required: ["topics", "audienceDescription", "contentGoals"],
388
+ },
389
+ });
390
+ if (s2.action !== "accept" || !s2.content) {
391
+ return {
392
+ content: [{ type: "text", text: "Onboarding cancelled." }],
393
+ structuredContent: { cancelled: true, message: "Onboarding cancelled." },
394
+ };
395
+ }
396
+ // Step 3 — Voice & platforms
397
+ const s3 = await server.elicitInput({
398
+ message: "Step 3 of 3: how do you write, and where do you publish?",
399
+ requestedSchema: {
400
+ type: "object",
401
+ properties: {
402
+ voice: { type: "string", title: "Writing voice", description: "e.g. direct and analytical, no corporate speak, sardonic, data-heavy" },
403
+ platforms: {
404
+ type: "array",
405
+ title: "Publishing platforms",
406
+ description: "Select all platforms you use",
407
+ items: { type: "string", enum: ["linkedin", "x", "blog", "newsletter", "medium", "instagram", "threads"] },
408
+ },
409
+ excludeTopics: { type: "string", title: "Topics to avoid (optional)", description: "Comma-separated topics Quillby should filter out" },
410
+ },
411
+ required: ["voice", "platforms"],
412
+ },
413
+ });
414
+ if (s3.action !== "accept" || !s3.content) {
415
+ return {
416
+ content: [{ type: "text", text: "Onboarding cancelled." }],
417
+ structuredContent: { cancelled: true, message: "Onboarding cancelled." },
418
+ };
419
+ }
420
+ const splitCSV = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
421
+ const toStrArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : splitCSV(v);
422
+ const onboardCtx = UserContextSchema.parse({
423
+ name: s1.content.name || undefined,
424
+ role: s1.content.role,
425
+ industry: s1.content.industry,
426
+ topics: splitCSV(s2.content.topics),
427
+ audienceDescription: s2.content.audienceDescription,
428
+ contentGoals: splitCSV(s2.content.contentGoals),
429
+ voice: s3.content.voice,
430
+ platforms: toStrArr(s3.content.platforms),
431
+ excludeTopics: s3.content.excludeTopics ? splitCSV(s3.content.excludeTopics) : [],
432
+ });
433
+ saveContext(onboardCtx);
434
+ const summary = `Profile saved!\n\nRole: ${onboardCtx.role} in ${onboardCtx.industry}\nTopics: ${onboardCtx.topics.join(", ")}\nPlatforms: ${onboardCtx.platforms.join(", ")}\nVoice: ${onboardCtx.voice}\n\nNext: call quillby_discover_feeds to set up your RSS sources.`;
435
+ return {
436
+ content: [{ type: "text", text: summary }],
437
+ structuredContent: { saved: true, profile: onboardCtx },
438
+ };
439
+ }
440
+ case "quillby_set_context": {
441
+ const context = UserContextSchema.parse(args.context);
442
+ saveContext(context);
443
+ return {
444
+ content: [{ type: "text", text: `Context saved. Role: ${context.role}. Topics: ${context.topics.join(", ")}. Platforms: ${context.platforms.join(", ")}.` }],
445
+ structuredContent: { saved: true, role: context.role, topics: context.topics, platforms: context.platforms },
446
+ };
447
+ }
448
+ case "quillby_get_context": {
449
+ if (!contextExists()) {
450
+ return { content: [{ type: "text", text: "No context saved. Run quillby_onboarding first." }], structuredContent: { error: "no_context" } };
451
+ }
452
+ const ctxData = loadContext();
453
+ return { content: [{ type: "text", text: JSON.stringify(ctxData, null, 2) }], structuredContent: ctxData };
454
+ }
455
+ case "quillby_add_feeds": {
456
+ const { urls } = args;
457
+ const result = appendSources(urls);
458
+ const totalAfterAdd = loadSources().length;
459
+ return {
460
+ content: [{ type: "text", text: `Added ${result.added} feed(s). Skipped ${result.skipped} duplicate(s). Total: ${totalAfterAdd}. Use quillby_fetch_articles to pull articles.` }],
461
+ structuredContent: { added: result.added, skipped: result.skipped, total: totalAfterAdd },
462
+ };
463
+ }
464
+ case "quillby_discover_feeds": {
465
+ const ctx = contextExists() ? loadContext() : null;
466
+ const { topics: topicOverride, locale = "en-US", country = "US" } = args;
467
+ const topics = topicOverride?.length ? topicOverride : (ctx?.topics ?? []);
468
+ if (topics.length === 0) {
469
+ return { content: [{ type: "text", text: "No topics in context. Run quillby_onboarding first." }], structuredContent: { error: "no_topics" } };
470
+ }
471
+ // 1. Google News RSS — one feed per topic, real-time, multilingual, any language
472
+ const googleUrls = getGoogleNewsFeeds(topics, locale, country);
473
+ // 2. Medium tag feeds — professional articles on any topic (healthcare, law, fashion, etc.)
474
+ const mediumUrls = getMediumTagFeeds(topics);
475
+ // 3. Feedly search — curated publication feeds per topic
476
+ const feedlyUrls = await getFeedlyFeeds(topics, 3);
477
+ // 4. Sampling — ask the model for relevant Reddit communities and niche RSS feeds
478
+ const samplingAvailable = !!(server.getClientCapabilities()?.sampling);
479
+ let samplingUrls = [];
480
+ if (samplingAvailable) {
481
+ const samplingPrompt = `The user is a content creator covering these topics: ${topics.join(", ")}.
482
+
483
+ Suggest niche content sources that broad news feeds would miss. For each suggestion:
484
+ - Reddit communities relevant to these topics: use the format reddit://r/<subreddit> (e.g. reddit://r/smallbusiness, reddit://r/medicine, reddit://r/farming, reddit://r/law)
485
+ - Niche industry association blogs, trade publication RSS feeds, or specialist Substack feeds: use standard https:// URLs
486
+
487
+ Pick communities and publications that match the industry, not tech/startup defaults. A clothing boutique owner needs fashion/retail communities. A health professional needs medical/wellness sources. A lawyer needs legal industry feeds.
488
+
489
+ Return ONLY a JSON array of strings. 10 items max. No explanation.`;
490
+ const raw = await sample(samplingPrompt, 600);
491
+ if (raw) {
492
+ try {
493
+ const match = raw.match(/\[.*\]/s);
494
+ if (match) {
495
+ const parsed = JSON.parse(match[0]);
496
+ samplingUrls = parsed.filter((u) => typeof u === "string" &&
497
+ (u.startsWith("http") || u.startsWith("reddit://")));
498
+ }
499
+ }
500
+ catch {
501
+ // ignore parse errors
502
+ }
503
+ }
504
+ }
505
+ const allUrls = [...new Set([...googleUrls, ...mediumUrls, ...feedlyUrls, ...samplingUrls])];
506
+ const result = appendSources(allUrls);
507
+ const discoverResult = {
508
+ topics,
509
+ googleNewsFeeds: googleUrls.length,
510
+ mediumTagFeeds: mediumUrls.length,
511
+ feedlyFeeds: feedlyUrls.length,
512
+ samplingFeeds: samplingUrls.length,
513
+ added: result.added,
514
+ skipped: result.skipped,
515
+ totalFeeds: loadSources().length,
516
+ };
517
+ return {
518
+ content: [{ type: "text", text: JSON.stringify(discoverResult, null, 2) }],
519
+ structuredContent: discoverResult,
520
+ };
521
+ }
522
+ case "quillby_list_feeds": {
523
+ const sources = loadSources();
524
+ const listFeedsResult = { count: sources.length, feeds: sources };
525
+ return {
526
+ content: [{ type: "text", text: sources.length ? JSON.stringify(listFeedsResult, null, 2) : "No feeds configured. Use quillby_add_feeds." }],
527
+ structuredContent: listFeedsResult,
528
+ };
529
+ }
530
+ case "quillby_fetch_articles": {
531
+ const { sources: overrideSources, slim } = args;
532
+ const sources = overrideSources?.length ? overrideSources : loadSources();
533
+ if (sources.length === 0) {
534
+ return { content: [{ type: "text", text: "No RSS sources configured. Use quillby_discover_feeds to add curated feeds, or quillby_add_feeds with manual URLs." }], structuredContent: { error: "no_sources" } };
535
+ }
536
+ const ctx = contextExists() ? loadContext() : null;
537
+ const topics = ctx?.topics ?? [];
538
+ const { articles, seenUrls } = await fetchArticles(sources, log, slim ?? false);
539
+ saveSeenUrls(seenUrls);
540
+ const scored = topics.length > 0 ? preScoreArticles(articles, topics) : articles.map((a) => ({ ...a, _preScore: 0 }));
541
+ const output = slim
542
+ ? scored.map(({ enrichedContent: _ec, ...rest }) => rest)
543
+ : scored;
544
+ const fetchResult = { feedsChecked: sources.length, articleCount: articles.length, slim: slim ?? false, articles: output };
545
+ return {
546
+ content: [{ type: "text", text: JSON.stringify(fetchResult, null, 2) }],
547
+ structuredContent: fetchResult,
548
+ };
549
+ }
550
+ case "quillby_read_article": {
551
+ const { url, title = "" } = args;
552
+ const content = await enrichArticle(url, title);
553
+ if (!content) {
554
+ return { content: [{ type: "text", text: "Could not retrieve article content (paywalled or fetch failed)." }], structuredContent: { content: null, error: "fetch_failed" } };
555
+ }
556
+ return { content: [{ type: "text", text: content }], structuredContent: { content } };
557
+ }
558
+ case "quillby_save_cards": {
559
+ const { cards: rawCards } = args;
560
+ const cards = rawCards.map((c) => CardInputSchema.parse(c));
561
+ if (cards.length === 0) {
562
+ return { content: [{ type: "text", text: "No cards provided." }], structuredContent: { saved: 0 } };
563
+ }
564
+ const outputDir = saveHarvestOutput(cards, new Set());
565
+ return { content: [{ type: "text", text: `Saved ${cards.length} card(s) to ${outputDir}.` }], structuredContent: { saved: cards.length, outputDir } };
566
+ }
567
+ case "quillby_list_cards": {
568
+ if (!latestHarvestExists()) {
569
+ return { content: [{ type: "text", text: "No harvest found. Fetch articles and save cards first." }], structuredContent: { error: "no_harvest" } };
570
+ }
571
+ const { limit, minScore } = args;
572
+ const bundle = loadLatestHarvest();
573
+ let cards = bundle.cards;
574
+ if (minScore != null) {
575
+ cards = cards.filter((c) => (c.relevanceScore ?? 0) >= minScore);
576
+ }
577
+ if (limit)
578
+ cards = cards.slice(0, limit);
579
+ const listCardsResult = { generatedAt: bundle.generatedAt, total: bundle.cards.length, showing: cards.length, cards: cards.map((c) => ({ id: c.id, title: c.title, source: c.source, relevanceScore: c.relevanceScore, thesis: c.thesis, trendTags: c.trendTags })) };
580
+ return {
581
+ content: [{ type: "text", text: JSON.stringify(listCardsResult, null, 2) }],
582
+ structuredContent: listCardsResult,
583
+ };
584
+ }
585
+ case "quillby_analyze_articles": {
586
+ const { topN: rawTopN } = args;
587
+ const topN = rawTopN ?? 15;
588
+ if (!contextExists()) {
589
+ return { content: [{ type: "text", text: "No context saved. Run quillby_onboarding first." }], structuredContent: { error: "no_context" } };
590
+ }
591
+ const ctx = loadContext();
592
+ const sources = loadSources();
593
+ if (sources.length === 0) {
594
+ return { content: [{ type: "text", text: "No RSS sources configured. Use quillby_discover_feeds first." }], structuredContent: { error: "no_sources" } };
595
+ }
596
+ const samplingAvailable = !!(server.getClientCapabilities()?.sampling);
597
+ if (!samplingAvailable) {
598
+ return { content: [{ type: "text", text: "Sampling not available in this client. Use quillby_fetch_articles + quillby_read_article + quillby_save_cards for manual analysis." }], structuredContent: { error: "sampling_unavailable" } };
599
+ }
600
+ // Fetch slim articles
601
+ const { articles, seenUrls } = await fetchArticles(sources, log, true);
602
+ saveSeenUrls(seenUrls);
603
+ if (articles.length === 0) {
604
+ return { content: [{ type: "text", text: "No new articles found. All items have been seen before." }], structuredContent: { error: "no_new_articles" } };
605
+ }
606
+ // Semantic scoring via Sampling (keyword fallback)
607
+ log(`Scoring ${articles.length} headlines semantically...`);
608
+ const headlineList = articles
609
+ .map((a, i) => `${i}: ${a.title} — ${a.snippet ?? ""}`)
610
+ .join("\n");
611
+ const scorePrompt = `You are scoring news headlines for a ${ctx.role} in ${ctx.industry ?? "their industry"}.
612
+
613
+ User topics: ${ctx.topics.join(", ")}
614
+ Audience: ${ctx.audienceDescription ?? "general"}
615
+ Goals: ${ctx.contentGoals.join(", ")}
616
+ Avoid: ${ctx.excludeTopics?.length ? ctx.excludeTopics.join(", ") : "nothing specified"}
617
+
618
+ Headlines (index: title — snippet):
619
+ ${headlineList}
620
+
621
+ Return ONLY a JSON array of integers — the indices of the top ${topN} most relevant headlines, ordered best first. No explanation.`;
622
+ const fbScoreSignalAnalyze = "";
623
+ const scoreRaw = await sample(scorePrompt + fbScoreSignalAnalyze, 400);
624
+ let topIndices = [];
625
+ if (scoreRaw) {
626
+ try {
627
+ const m = scoreRaw.match(/\[[\s\S]*\]/);
628
+ if (m) {
629
+ const parsed = JSON.parse(m[0]);
630
+ topIndices = parsed
631
+ .filter((x) => typeof x === "number" && x >= 0 && x < articles.length)
632
+ .slice(0, topN);
633
+ }
634
+ }
635
+ catch { /* fall through to keyword fallback */ }
636
+ }
637
+ if (topIndices.length === 0) {
638
+ topIndices = preScoreArticles(articles, ctx.topics)
639
+ .slice(0, topN)
640
+ .map((a) => articles.findIndex((s) => s.link === a.link))
641
+ .filter((i) => i >= 0);
642
+ }
643
+ const topArticles = topIndices.map((i) => articles[i]).filter(Boolean);
644
+ // Enrich top articles
645
+ const enriched = [];
646
+ for (const article of topArticles) {
647
+ const content = await enrichArticle(article.link, article.title ?? "");
648
+ enriched.push({ title: article.title ?? "", url: article.link, snippet: article.snippet ?? "", content });
649
+ }
650
+ // Build analysis prompt
651
+ const articleBlobs = enriched.map((a, i) => `## Article ${i + 1}: ${a.title}\nURL: ${a.url}\n\n${a.content ?? a.snippet}`).join("\n\n---\n\n");
652
+ const voiceBlock = loadMemory().voiceExamples.length
653
+ ? `\n\nUser voice examples (study and match this style — do NOT smooth it out):\n${loadMemory().voiceExamples.map((e, i) => `[${i + 1}]\n${e}`).join("\n\n")}`
654
+ : `\n\nUser voice: ${ctx.voice ?? "direct and authentic"}`;
655
+ const analysisPrompt = `You are an expert content strategist. Analyze these articles for a ${ctx.role} in ${ctx.industry ?? "their industry"}.
656
+
657
+ User topics: ${ctx.topics.join(", ")}
658
+ User audience: ${ctx.audienceDescription ?? "general"}
659
+ User platforms: ${ctx.platforms.join(", ")}${voiceBlock}
660
+
661
+ ${articleBlobs}
662
+
663
+ For each article, produce a JSON object with these fields:
664
+ - title (string)
665
+ - url (string)
666
+ - source (string — domain of URL)
667
+ - thesis (string — one sharp sentence: the single most important insight)
668
+ - relevanceScore (number 1-10)
669
+ - trendTags (array of 3-5 short tags)
670
+ - contentAngles (array of 2-3 post angles for the user's platforms)
671
+ - keyQuotes (array of 1-2 memorable lines or stats from the article)
672
+
673
+ Return ONLY a valid JSON array of these objects, no prose.`;
674
+ const raw = await sample(analysisPrompt, 2000);
675
+ if (!raw) {
676
+ return { content: [{ type: "text", text: "Sampling returned no result. Try again or use quillby_fetch_articles + quillby_save_cards manually." }], structuredContent: { error: "sampling_failed" } };
677
+ }
678
+ let cards;
679
+ try {
680
+ const match = raw.match(/\[.*\]/s);
681
+ if (!match)
682
+ throw new Error("No JSON array in response");
683
+ cards = JSON.parse(match[0]);
684
+ }
685
+ catch (e) {
686
+ return { content: [{ type: "text", text: `Sampling returned malformed JSON. Raw response:\n${raw}` }], structuredContent: { error: "malformed_json", raw } };
687
+ }
688
+ const parsed = cards.map((c) => CardInputSchema.parse(c));
689
+ const outputDir = saveHarvestOutput(parsed, seenUrls);
690
+ const analyzeResult = { analyzed: parsed.length, outputDir, cards: parsed.map((c) => ({ title: c.title, relevanceScore: c.relevanceScore, thesis: c.thesis })) };
691
+ return {
692
+ content: [{ type: "text", text: JSON.stringify(analyzeResult, null, 2) }],
693
+ structuredContent: analyzeResult,
694
+ };
695
+ }
696
+ case "quillby_daily_brief": {
697
+ const { topN: rawTopN } = args;
698
+ const topN = rawTopN ?? 10;
699
+ if (!contextExists()) {
700
+ return { content: [{ type: "text", text: "No context saved. Run quillby_onboarding first." }], structuredContent: { error: "no_context" } };
701
+ }
702
+ const ctx = loadContext();
703
+ const sources = loadSources();
704
+ if (sources.length === 0) {
705
+ return { content: [{ type: "text", text: "No RSS sources configured. Use quillby_discover_feeds first." }], structuredContent: { error: "no_sources" } };
706
+ }
707
+ const samplingAvailable = !!(server.getClientCapabilities()?.sampling);
708
+ if (!samplingAvailable) {
709
+ return { content: [{ type: "text", text: "Sampling not available in this client. Use quillby_analyze_articles or the manual workflow instead." }], structuredContent: { error: "sampling_unavailable" } };
710
+ }
711
+ // Pass 1: headlines only — fast, no content fetching
712
+ log(`Daily brief: fetching headlines from ${sources.length} feeds...`);
713
+ const { articles: slimArticles, seenUrls } = await fetchArticles(sources, log, true);
714
+ saveSeenUrls(seenUrls);
715
+ if (slimArticles.length === 0) {
716
+ return { content: [{ type: "text", text: "No new articles found. All items have been seen before." }], structuredContent: { error: "no_new_articles" } };
717
+ }
718
+ // Pass 1b: Sampling-based semantic scoring (not keyword matching)
719
+ log(`Scoring ${slimArticles.length} headlines semantically via Sampling...`);
720
+ const headlineList = slimArticles
721
+ .map((a, i) => `${i}: ${a.title} — ${a.snippet ?? ""}`)
722
+ .join("\n");
723
+ const scorePrompt = `You are scoring news headlines for a ${ctx.role} in ${ctx.industry ?? "their industry"}.
724
+
725
+ User topics: ${ctx.topics.join(", ")}
726
+ Audience: ${ctx.audienceDescription ?? "general"}
727
+ Goals: ${ctx.contentGoals.join(", ")}
728
+ Avoid: ${ctx.excludeTopics?.length ? ctx.excludeTopics.join(", ") : "nothing specified"}
729
+
730
+ Headlines (index: title — snippet):
731
+ ${headlineList}
732
+
733
+ Return ONLY a JSON array of integers — the indices of the top ${topN} most relevant headlines, ordered best first. No explanation.`;
734
+ const fbScoreSignal = "";
735
+ const scoreRaw = await sample(scorePrompt + fbScoreSignal, 400);
736
+ let topIndices = [];
737
+ if (scoreRaw) {
738
+ try {
739
+ const match = scoreRaw.match(/\[[\s\S]*\]/);
740
+ if (match) {
741
+ const parsed = JSON.parse(match[0]);
742
+ topIndices = parsed
743
+ .filter((x) => typeof x === "number" && x >= 0 && x < slimArticles.length)
744
+ .slice(0, topN);
745
+ }
746
+ }
747
+ catch {
748
+ // fall back to keyword pre-scoring
749
+ }
750
+ }
751
+ if (topIndices.length === 0) {
752
+ const keywordScored = preScoreArticles(slimArticles, ctx.topics);
753
+ topIndices = keywordScored
754
+ .slice(0, topN)
755
+ .map((a) => slimArticles.findIndex((s) => s.link === a.link))
756
+ .filter((i) => i >= 0);
757
+ }
758
+ const topSlim = topIndices.map((i) => slimArticles[i]).filter(Boolean);
759
+ // Pass 2: deep-read only the selected articles
760
+ log(`Deep-reading ${topSlim.length} selected articles...`);
761
+ const enriched = [];
762
+ for (const article of topSlim) {
763
+ const content = await enrichArticle(article.link, article.title ?? "");
764
+ enriched.push({
765
+ title: article.title ?? "",
766
+ source: article.source ?? article.link,
767
+ link: article.link,
768
+ snippet: article.snippet ?? "",
769
+ content,
770
+ });
771
+ }
772
+ // Pass 3: Sampling generates full cards in one call
773
+ log("Generating content cards via Sampling...");
774
+ const voiceBlock3 = loadMemory().voiceExamples.length
775
+ ? `\n\nVoice examples — match this style, amplify the strongest quirks:\n${loadMemory().voiceExamples.map((e, i) => `[${i + 1}]\n${e}`).join("\n\n")}`
776
+ : `\n\nVoice: ${ctx.voice ?? "direct and authentic"}`;
777
+ const articleBlobs = enriched
778
+ .map((a, i) => `## Article ${i + 1}: ${a.title}\nURL: ${a.link}\n\n${a.content ?? a.snippet}`)
779
+ .join("\n\n---\n\n");
780
+ const cardPrompt = `You are a content strategist. Analyze these articles for a ${ctx.role} in ${ctx.industry ?? "their industry"}.
781
+
782
+ User topics: ${ctx.topics.join(", ")}
783
+ User platforms: ${ctx.platforms.join(", ")}${voiceBlock3}
784
+
785
+ ${articleBlobs}
786
+
787
+ For each article produce a JSON object with these exact fields:
788
+ - title (string)
789
+ - source (string — domain of URL)
790
+ - link (string — article URL exactly as provided above)
791
+ - thesis (string — one sharp sentence: the single most important takeaway)
792
+ - relevanceScore (number 0-10)
793
+ - relevanceReason (string — one sentence why this is useful for the user)
794
+ - keyInsights (array of 2-3 specific facts or data points from the article)
795
+ - angleOptions (array of 3 distinct post angles matching the user voice and platforms)
796
+ - hookOptions (array of 3 opening lines — specific, no filler openers, no rhetorical questions that give away the answer)
797
+ - trendTags (array of 3-5 short tags)
798
+ - transposabilityHint (string — how to make this universal beyond just the news hook)
799
+
800
+ Return ONLY a valid JSON array of these objects, no prose.`;
801
+ const cardRaw = await sample(cardPrompt, 4000);
802
+ if (!cardRaw) {
803
+ return { content: [{ type: "text", text: "Sampling returned no result for card generation. Try again." }], structuredContent: { error: "sampling_failed" } };
804
+ }
805
+ let rawBriefCards;
806
+ try {
807
+ const match = cardRaw.match(/\[[\s\S]*\]/);
808
+ if (!match)
809
+ throw new Error("No JSON array in response");
810
+ rawBriefCards = JSON.parse(match[0]);
811
+ }
812
+ catch {
813
+ return { content: [{ type: "text", text: `Card generation returned malformed JSON.\nRaw:\n${cardRaw}` }], structuredContent: { error: "malformed_json", raw: cardRaw } };
814
+ }
815
+ const briefCards = rawBriefCards.map((c) => CardInputSchema.parse(c));
816
+ saveHarvestOutput(briefCards, seenUrls);
817
+ const savedBundle = loadLatestHarvest();
818
+ const briefResult = {
819
+ date: new Date().toISOString().split("T")[0],
820
+ feedsChecked: sources.length,
821
+ headlinesSeen: slimArticles.length,
822
+ deepRead: enriched.length,
823
+ cardsGenerated: savedBundle.cards.length,
824
+ brief: savedBundle.cards
825
+ .sort((a, b) => (b.relevanceScore ?? 0) - (a.relevanceScore ?? 0))
826
+ .map((c) => ({
827
+ id: c.id,
828
+ score: c.relevanceScore,
829
+ title: c.title,
830
+ thesis: c.thesis,
831
+ topAngle: c.angleOptions?.[0] ?? null,
832
+ topHook: c.hookOptions?.[0] ?? null,
833
+ trendTags: c.trendTags,
834
+ })),
835
+ };
836
+ return {
837
+ content: [{ type: "text", text: JSON.stringify(briefResult, null, 2) }],
838
+ structuredContent: briefResult,
839
+ };
840
+ }
841
+ case "quillby_get_card": {
842
+ if (!latestHarvestExists()) {
843
+ return { content: [{ type: "text", text: "No harvest found." }], structuredContent: { error: "no_harvest" } };
844
+ }
845
+ const { cardId } = args;
846
+ const bundle = loadLatestHarvest();
847
+ const card = bundle.cards.find((c) => c.id === cardId);
848
+ if (!card) {
849
+ return { content: [{ type: "text", text: `Card #${cardId} not found. Available: ${bundle.cards.map((c) => c.id).join(", ")}.` }], structuredContent: { error: "not_found", cardId } };
850
+ }
851
+ return { content: [{ type: "text", text: JSON.stringify(card, null, 2) }], structuredContent: card };
852
+ }
853
+ case "quillby_save_draft": {
854
+ const { content, platform, cardId, addToVoiceExamples } = args;
855
+ const filePath = saveDraft(content, platform, cardId);
856
+ if (addToVoiceExamples)
857
+ appendVoiceExample(content);
858
+ const savedMsg = addToVoiceExamples
859
+ ? `Draft saved to ${filePath}. Added to voice memory.`
860
+ : `Draft saved to ${filePath}.`;
861
+ return { content: [{ type: "text", text: savedMsg }], structuredContent: { saved: true, platform, filePath, voiceExampleAdded: addToVoiceExamples ?? false } };
862
+ }
863
+ case "quillby_generate_post": {
864
+ const { cardId: genCardId, platform: genPlatform, angle } = args;
865
+ if (!latestHarvestExists()) {
866
+ return { content: [{ type: "text", text: "No harvest found. Run quillby_daily_brief or quillby_analyze_articles first." }], structuredContent: { error: "no_harvest" } };
867
+ }
868
+ if (!contextExists()) {
869
+ return { content: [{ type: "text", text: "No context saved. Run quillby_onboarding first." }], structuredContent: { error: "no_context" } };
870
+ }
871
+ const genSamplingAvailable = !!(server.getClientCapabilities()?.sampling);
872
+ if (!genSamplingAvailable) {
873
+ return { content: [{ type: "text", text: "Sampling not available. Write the post yourself and use quillby_save_draft to persist it." }], structuredContent: { error: "sampling_unavailable" } };
874
+ }
875
+ const genBundle = loadLatestHarvest();
876
+ const genCard = genBundle.cards.find((c) => c.id === genCardId);
877
+ if (!genCard) {
878
+ return { content: [{ type: "text", text: `Card #${genCardId} not found. Available: ${genBundle.cards.map((c) => c.id).join(", ")}.` }], structuredContent: { error: "not_found", cardId: genCardId } };
879
+ }
880
+ const genCtx = loadContext();
881
+ const guide = PLATFORM_GUIDES[genPlatform];
882
+ if (!guide) {
883
+ return { content: [{ type: "text", text: `Unknown platform: "${genPlatform}". Available: ${Object.keys(PLATFORM_GUIDES).join(", ")}.` }], structuredContent: { error: "unknown_platform", platform: genPlatform } };
884
+ }
885
+ const chosenAngle = angle ?? genCard.angleOptions?.[0] ?? genCard.thesis;
886
+ const genVoiceBlock = loadMemory().voiceExamples.length
887
+ ? `Voice examples — read these carefully. Match the register, rhythm, and vocabulary exactly. Oversteer on the strongest quirks:\n${loadMemory().voiceExamples.map((e, i) => `[${i + 1}]\n${e}`).join("\n\n")}`
888
+ : `Voice description: ${genCtx.voice ?? "direct and authentic"}`;
889
+ const genAnglesHint = "";
890
+ const generatePrompt = `You are writing a ${genPlatform} post for ${genCtx.name ?? "a content creator"} — a ${genCtx.role} in ${genCtx.industry ?? "their industry"}.
891
+
892
+ ## User profile
893
+ - Audience: ${genCtx.audienceDescription ?? "general"}
894
+ - Goals: ${genCtx.contentGoals.join(", ")}
895
+ - Platforms: ${genCtx.platforms.join(", ")}
896
+
897
+ ## ${genVoiceBlock}
898
+
899
+ ## Source card
900
+ Title: ${genCard.title}
901
+ Thesis: ${genCard.thesis}
902
+ Angle to use: ${chosenAngle}
903
+ Key insights: ${genCard.keyInsights?.join(" | ") ?? ""}
904
+ Trend tags: ${genCard.trendTags?.join(", ") ?? ""}
905
+ Transposability hint: ${genCard.transposabilityHint ?? ""}
906
+ Hook options (pick the best or write a stronger one): ${genCard.hookOptions?.join(" | ") ?? ""}
907
+
908
+ ## Platform guide
909
+ ${guide}${genAnglesHint}
910
+
911
+ ## Absolute rules — any violation produces an unusable draft
912
+ - NEVER use: "It's not X, it's Y" contrasts, em-dash clusters (1 max per post), bullet lists masquerading as prose
913
+ - NEVER use these words: "game-changer", "transformative", "innovative", "powerful", "exciting", "impactful", "leverage", "unlock", "dive into"
914
+ - NEVER use filler openers: "In today's world", "In an era of", "Let's talk about", "Here's the thing:", "The truth is:"
915
+ - NEVER use rhetorical question openers that give away the answer
916
+ - NEVER use motivational closings: "Remember: X matters", "Don't forget to X"
917
+ - NEVER smooth out the rough edges — the rough edges are the voice
918
+ - Write the post only. No intro sentence, no commentary, no "Here is the post:".`;
919
+ log(`Generating ${genPlatform} post for card #${genCardId}...`);
920
+ const draft = await sample(generatePrompt, 2000);
921
+ if (!draft) {
922
+ return { content: [{ type: "text", text: "Sampling returned no result. Try again." }], structuredContent: { error: "sampling_failed" } };
923
+ }
924
+ const draftPath = saveDraft(draft.trim(), genPlatform, genCardId);
925
+ const generateResult = { platform: genPlatform, cardId: genCardId, angle: chosenAngle, savedTo: draftPath, draft: draft.trim() };
926
+ return {
927
+ content: [{ type: "text", text: JSON.stringify(generateResult, null, 2) }],
928
+ structuredContent: generateResult,
929
+ };
930
+ }
931
+ case "quillby_remember": {
932
+ const { voiceExamples: newExamples } = args;
933
+ for (const ex of newExamples)
934
+ appendVoiceExample(ex);
935
+ return {
936
+ content: [{ type: "text", text: `Added ${newExamples.length} voice example(s) to memory.` }],
937
+ structuredContent: { added: newExamples.length },
938
+ };
939
+ }
940
+ default:
941
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, structuredContent: { error: "unknown_tool", toolName: name } };
942
+ }
943
+ }
944
+ catch (err) {
945
+ const message = err instanceof Error ? err.message : String(err);
946
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true, structuredContent: { error: message } };
947
+ }
948
+ });
949
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCES }));
950
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
951
+ const { uri } = req.params;
952
+ switch (uri) {
953
+ case "quillby://context": {
954
+ const text = contextExists()
955
+ ? JSON.stringify(loadContext(), null, 2)
956
+ : JSON.stringify({ error: "No context saved. Run quillby_onboarding first." });
957
+ return { contents: [{ uri, mimeType: "application/json", text }] };
958
+ }
959
+ case "quillby://memory": {
960
+ const text = JSON.stringify(loadMemory(), null, 2);
961
+ return { contents: [{ uri, mimeType: "application/json", text }] };
962
+ }
963
+ case "quillby://harvest/latest": {
964
+ const text = latestHarvestExists()
965
+ ? JSON.stringify(loadLatestHarvest(), null, 2)
966
+ : JSON.stringify({ error: "No harvest yet. Run quillby_fetch_articles and quillby_save_cards." });
967
+ return { contents: [{ uri, mimeType: "application/json", text }] };
968
+ }
969
+ case "quillby://feeds": {
970
+ const sources = loadSources();
971
+ return { contents: [{ uri, mimeType: "text/plain", text: sources.length ? sources.join("\n") : "# No feeds configured." }] };
972
+ }
973
+ default:
974
+ throw new Error(`Unknown resource: ${uri}`);
975
+ }
976
+ });
977
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
978
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
979
+ const { name } = req.params;
980
+ switch (name) {
981
+ case "quillby_onboarding": {
982
+ const exists = contextExists();
983
+ const existing = exists ? loadContext() : null;
984
+ return {
985
+ description: "Quillby onboarding",
986
+ messages: [
987
+ {
988
+ role: "user",
989
+ content: {
990
+ type: "text",
991
+ text: exists
992
+ ? `I have a saved profile:\n\n${contextToPromptText(existing, loadMemory())}\n\nUpdate it?`
993
+ : "Set up Quillby for my content workflow.",
994
+ },
995
+ },
996
+ {
997
+ role: "assistant",
998
+ content: {
999
+ type: "text",
1000
+ text: exists
1001
+ ? "I can see your profile. Tell me what to change and I will call quillby_set_context."
1002
+ : ONBOARDING_PROMPT,
1003
+ },
1004
+ },
1005
+ ],
1006
+ };
1007
+ }
1008
+ case "quillby_workflow": {
1009
+ const platformGuideText = Object.entries(PLATFORM_GUIDES)
1010
+ .map(([p, g]) => `**${p}**: ${g}`)
1011
+ .join("\n\n");
1012
+ const ctx = contextExists() ? loadContext() : null;
1013
+ const mem = loadMemory();
1014
+ const voiceSection = mem.voiceExamples.length
1015
+ ? `### Voice reference (read before writing any draft)
1016
+
1017
+ These are approved posts that define the target voice. Match the register, rhythm, and directness. Oversteer — if it feels too contained, it's wrong.
1018
+
1019
+ ${mem.voiceExamples.map((e, i) => `**Example ${i + 1}:**\n\`\`\`\n${e}\n\`\`\``).join("\n\n")}`
1020
+ : ctx?.voice
1021
+ ? `### Voice\n\n${ctx.voice}\n\nNo approved examples yet. Use quillby_remember to add voice examples.`
1022
+ : "### Voice\n\nNo profile saved. Run quillby_onboarding first.";
1023
+ const workflowText = `## Quillby Workflow
1024
+
1025
+ Quillby handles file I/O and data plumbing. All editorial judgment lives in the model.
1026
+
1027
+ ### Setup (once)
1028
+ 1. Run quillby_onboarding prompt, collect answers, call quillby_set_context.
1029
+ 2. Call quillby_discover_feeds — it matches your topics against a curated seed list and optionally expands it via Sampling. No manual feed hunting needed.
1030
+
1031
+ ### Daily workflow — Automated (when Sampling is available)
1032
+ 1. Call quillby_analyze_articles (limit: 8–12). Quillby fetches articles, pre-scores by topic overlap, enriches the top N, sends them to you via Sampling, and saves the resulting cards automatically.
1033
+ 2. Call quillby_list_cards (minScore: 7) to see the strongest cards.
1034
+ 3. Call quillby_get_card for the card you want to post about.
1035
+ 4. Write the post using the platform guide below.
1036
+ 5. Call quillby_save_draft to persist it.
1037
+
1038
+ ### Daily workflow — Manual (when Sampling is unavailable)
1039
+ 1. Call quillby_fetch_articles with slim=true — returns a headline index sorted by pre-score. Fast, no content fetching.
1040
+ 2. Read quillby://context. Identify the most promising articles by title and _preScore.
1041
+ 3. Call quillby_read_article for each article you want to read in full.
1042
+ 4. Score relevance yourself. Generate card fields.
1043
+ 5. Call quillby_save_cards with your analyzed cards.
1044
+ 6. Call quillby_get_card for the card you want to post about.
1045
+ 7. Write the post using the platform guide below.
1046
+ 8. Call quillby_save_draft to persist it.
1047
+
1048
+ ### Voice rules (apply before writing any draft)
1049
+ - Read the user's voice examples from quillby://memory. Identify the 2-3 strongest stylistic quirks. Amplify them — oversteer, not understeer.
1050
+ - BANNED: “It’s not X, it’s Y” contrasts. Em-dash clusters. Bullet lists as prose. “Game-changer”, “transformative”, “powerful”, “unlock”, “leverage”, “dive into”. Filler openers (“In today’s world”, “Here’s the thing”). Emoji stacking. Numbered listicles. Motivational closings.
1051
+ - Write like the user, not like an assistant helping the user.
1052
+
1053
+ ${voiceSection}
1054
+
1055
+ ### Platform guides
1056
+
1057
+ ${platformGuideText}`;
1058
+ return {
1059
+ description: "Quillby workflow",
1060
+ messages: [
1061
+ { role: "user", content: { type: "text", text: "How do I use Quillby?" } },
1062
+ { role: "assistant", content: { type: "text", text: workflowText } },
1063
+ ],
1064
+ };
1065
+ }
1066
+ default:
1067
+ throw new Error(`Unknown prompt: ${name}`);
1068
+ }
1069
+ });
1070
+ // ---------------------------------------------------------------------------
1071
+ // Scheduled autonomous harvest
1072
+ // ---------------------------------------------------------------------------
1073
+ async function runScheduledHarvest() {
1074
+ const tag = "[quillby-schedule]";
1075
+ if (!contextExists()) {
1076
+ process.stderr.write(`${tag} No profile saved — skipping.\n`);
1077
+ return;
1078
+ }
1079
+ const ctx = loadContext();
1080
+ const sources = loadSources();
1081
+ if (sources.length === 0) {
1082
+ process.stderr.write(`${tag} No feeds configured — skipping.\n`);
1083
+ return;
1084
+ }
1085
+ const topN = parseInt(process.env.Quillby_SCHEDULE_TOP_N ?? "15", 10);
1086
+ process.stderr.write(`${tag} Fetching articles from ${sources.length} feeds...\n`);
1087
+ try {
1088
+ const { articles, seenUrls } = await fetchArticles(sources, (msg) => process.stderr.write(`${tag} ${msg}\n`), true);
1089
+ saveSeenUrls(seenUrls);
1090
+ if (articles.length === 0) {
1091
+ process.stderr.write(`${tag} No new articles.\n`);
1092
+ return;
1093
+ }
1094
+ const top = preScoreArticles(articles, ctx.topics).slice(0, topN);
1095
+ const cards = top.map((a) => CardInputSchema.parse({
1096
+ title: a.title ?? "Untitled",
1097
+ source: (() => { try {
1098
+ return new URL(a.link).hostname;
1099
+ }
1100
+ catch {
1101
+ return a.link;
1102
+ } })(),
1103
+ link: a.link,
1104
+ thesis: a.snippet ?? a.title ?? "",
1105
+ trendTags: [],
1106
+ }));
1107
+ const outputDir = saveHarvestOutput(cards, seenUrls);
1108
+ process.stderr.write(`${tag} Done. ${cards.length} card(s) saved to ${outputDir}.\n`);
1109
+ }
1110
+ catch (err) {
1111
+ process.stderr.write(`${tag} Error: ${err instanceof Error ? err.message : String(err)}\n`);
1112
+ }
1113
+ }
1114
+ function scheduleDaily(timeStr, fn) {
1115
+ const parts = timeStr.split(":");
1116
+ const hour = parseInt(parts[0] ?? "", 10);
1117
+ const minute = parseInt(parts[1] ?? "0", 10);
1118
+ if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
1119
+ process.stderr.write(`[quillby-schedule] Invalid Quillby_SCHEDULE "${timeStr}" — expected HH:MM. Scheduling disabled.\n`);
1120
+ return;
1121
+ }
1122
+ const msUntilNext = () => {
1123
+ const now = new Date();
1124
+ const next = new Date(now);
1125
+ next.setHours(hour, minute, 0, 0);
1126
+ if (next.getTime() <= now.getTime())
1127
+ next.setDate(next.getDate() + 1);
1128
+ return next.getTime() - now.getTime();
1129
+ };
1130
+ const tick = () => {
1131
+ const delay = msUntilNext();
1132
+ process.stderr.write(`[quillby-schedule] Next harvest at ${timeStr} (in ${Math.round(delay / 60000)} min).\n`);
1133
+ setTimeout(async () => { await fn(); tick(); }, delay).unref();
1134
+ };
1135
+ tick();
1136
+ }
1137
+ // ---------------------------------------------------------------------------
1138
+ const TRANSPORT_MODE = process.env.Quillby_TRANSPORT ?? "stdio";
1139
+ if (TRANSPORT_MODE === "http") {
1140
+ // Stateful HTTP mode: each client session gets its own transport instance.
1141
+ // A single shared Server handles all sessions via per-request transports.
1142
+ const PORT = parseInt(process.env.PORT ?? "3000", 10);
1143
+ // Map of sessionId → transport, so we can route GET/DELETE back to the right session.
1144
+ const sessions = new Map();
1145
+ const httpServer = http.createServer(async (req, res) => {
1146
+ const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
1147
+ // A2A agent card — served unauthenticated so other agents can discover capabilities
1148
+ if (url.pathname === "/.well-known/agent.json") {
1149
+ const baseUrl = process.env.Quillby_BASE_URL ?? `http://localhost:${PORT}`;
1150
+ const agentCard = {
1151
+ name: "Quillby",
1152
+ description: "Guided Research & Insight Synthesis Tool — RSS content intelligence MCP server. Fetches, scores, and structures articles into content cards for social media posts.",
1153
+ url: `${baseUrl}/mcp`,
1154
+ version: "0.4.0",
1155
+ capabilities: {
1156
+ streaming: true,
1157
+ pushNotifications: false,
1158
+ stateTransitionHistory: false,
1159
+ },
1160
+ authentication: {
1161
+ schemes: process.env.Quillby_HTTP_TOKEN ? ["Bearer"] : ["None"],
1162
+ },
1163
+ defaultInputModes: ["application/json"],
1164
+ defaultOutputModes: ["application/json"],
1165
+ skills: [
1166
+ {
1167
+ id: "content_harvest",
1168
+ name: "Content Harvest",
1169
+ description: "Fetch articles from RSS feeds, score for relevance, structure into content cards.",
1170
+ tags: ["rss", "content", "feeds", "articles"],
1171
+ examples: ["Run quillby_daily_brief", "Fetch and analyze articles"],
1172
+ },
1173
+ {
1174
+ id: "post_generation",
1175
+ name: "Post Generation",
1176
+ description: "Generate platform-specific social media posts from content cards using the user voice profile.",
1177
+ tags: ["linkedin", "twitter", "blog", "newsletter"],
1178
+ examples: ["Generate a LinkedIn post from card #3"],
1179
+ },
1180
+ {
1181
+ id: "feed_management",
1182
+ name: "Feed Management",
1183
+ description: "Discover, add, and list RSS feed sources.",
1184
+ tags: ["rss", "feeds", "discovery"],
1185
+ examples: ["Discover feeds for AI topics", "Add a new RSS feed"],
1186
+ },
1187
+ ],
1188
+ };
1189
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }).end(JSON.stringify(agentCard, null, 2));
1190
+ return;
1191
+ }
1192
+ if (url.pathname !== "/mcp") {
1193
+ res.writeHead(404).end("Not found");
1194
+ return;
1195
+ }
1196
+ // Bearer token auth — enforced when Quillby_HTTP_TOKEN is set
1197
+ const BEARER_TOKEN = process.env.Quillby_HTTP_TOKEN;
1198
+ if (BEARER_TOKEN) {
1199
+ const authHeader = req.headers.authorization ?? "";
1200
+ const match = authHeader.match(/^Bearer (.+)$/i);
1201
+ const tokenValid = match != null &&
1202
+ match[1].length === BEARER_TOKEN.length &&
1203
+ timingSafeEqual(Buffer.from(match[1]), Buffer.from(BEARER_TOKEN));
1204
+ if (!tokenValid) {
1205
+ res.writeHead(401, { "WWW-Authenticate": 'Bearer realm="quillby-mcp"' }).end("Unauthorized");
1206
+ return;
1207
+ }
1208
+ }
1209
+ // Route GET/DELETE to existing session transport
1210
+ if (req.method === "GET" || req.method === "DELETE") {
1211
+ const sessionId = req.headers["mcp-session-id"];
1212
+ if (!sessionId || !sessions.has(sessionId)) {
1213
+ res.writeHead(400).end("Missing or unknown mcp-session-id");
1214
+ return;
1215
+ }
1216
+ const existing = sessions.get(sessionId);
1217
+ await existing.handleRequest(req, res);
1218
+ return;
1219
+ }
1220
+ // POST — new or existing session
1221
+ if (req.method === "POST") {
1222
+ // Read body
1223
+ const chunks = [];
1224
+ for await (const chunk of req)
1225
+ chunks.push(chunk);
1226
+ const body = Buffer.concat(chunks).toString("utf-8");
1227
+ let parsedBody;
1228
+ try {
1229
+ parsedBody = JSON.parse(body);
1230
+ }
1231
+ catch {
1232
+ parsedBody = undefined;
1233
+ }
1234
+ const sessionId = req.headers["mcp-session-id"];
1235
+ if (sessionId && sessions.has(sessionId)) {
1236
+ // Existing session
1237
+ await sessions.get(sessionId).handleRequest(req, res, parsedBody);
1238
+ return;
1239
+ }
1240
+ // New session
1241
+ const transport = new StreamableHTTPServerTransport({
1242
+ sessionIdGenerator: () => randomUUID(),
1243
+ });
1244
+ sessions.set(transport.sessionId ?? randomUUID(), transport);
1245
+ transport.onclose = () => {
1246
+ if (transport.sessionId)
1247
+ sessions.delete(transport.sessionId);
1248
+ };
1249
+ // Each new session connects a fresh Server clone sharing the same handlers.
1250
+ // Because @modelcontextprotocol/sdk v1.x Server is not multi-transport,
1251
+ // we create a new Server per session but reuse all the registered handlers
1252
+ // by reconnecting the same `server` instance (which is stateless wrt transport).
1253
+ await server.connect(transport);
1254
+ await transport.handleRequest(req, res, parsedBody);
1255
+ return;
1256
+ }
1257
+ res.writeHead(405).end("Method not allowed");
1258
+ });
1259
+ httpServer.listen(PORT, () => {
1260
+ process.stderr.write(`Quillby MCP server listening on http://localhost:${PORT}/mcp\n`);
1261
+ if (!process.env.Quillby_HTTP_TOKEN) {
1262
+ process.stderr.write("WARNING: Quillby_HTTP_TOKEN is not set — the /mcp endpoint is unprotected.\n");
1263
+ }
1264
+ });
1265
+ }
1266
+ else {
1267
+ // Default: stdio (local MCP clients — Claude Desktop, VS Code, Cursor)
1268
+ const transport = new StdioServerTransport();
1269
+ await server.connect(transport);
1270
+ }
1271
+ // Scheduled autonomous harvest — fires daily at Quillby_SCHEDULE (HH:MM local time).
1272
+ // Runs regardless of transport mode but works best as an HTTP daemon.
1273
+ // In stdio mode it only fires while an MCP client has the server open.
1274
+ const Quillby_SCHEDULE = process.env.Quillby_SCHEDULE;
1275
+ if (Quillby_SCHEDULE) {
1276
+ scheduleDaily(Quillby_SCHEDULE, runScheduledHarvest);
1277
+ }
1278
+ //# sourceMappingURL=server.js.map