@telepat/ideon 0.1.7 → 0.1.13

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 (2) hide show
  1. package/dist/ideon.js +896 -227
  2. package/package.json +1 -1
package/dist/ideon.js CHANGED
@@ -12,15 +12,87 @@ import { z } from "zod";
12
12
  var contentTypeValues = [
13
13
  "article",
14
14
  "blog-post",
15
- "x-thread",
16
- "x-post",
17
- "reddit-post",
18
15
  "linkedin-post",
19
16
  "newsletter",
20
- "landing-page-copy"
17
+ "press-release",
18
+ "reddit-post",
19
+ "science-paper",
20
+ "x-post",
21
+ "x-thread"
22
+ ];
23
+ var writingStyleValues = [
24
+ "academic",
25
+ "analytical",
26
+ "authoritative",
27
+ "conversational",
28
+ "empathetic",
29
+ "friendly",
30
+ "journalistic",
31
+ "minimalist",
32
+ "persuasive",
33
+ "playful",
34
+ "professional",
35
+ "storytelling",
36
+ "technical"
37
+ ];
38
+ var contentIntentValues = [
39
+ "announcement",
40
+ "case-study",
41
+ "cornerstone",
42
+ "counterargument",
43
+ "critique-review",
44
+ "deep-dive-analysis",
45
+ "how-to-guide",
46
+ "interview-q-and-a",
47
+ "listicle",
48
+ "opinion-piece",
49
+ "personal-essay",
50
+ "roundup-curation",
51
+ "tutorial"
21
52
  ];
22
- var writingStyleValues = ["professional", "friendly", "technical", "academic", "opinionated", "storytelling"];
23
53
  var targetLengthValues = ["small", "medium", "large"];
54
+ var targetLengthAliasWordCounts = {
55
+ small: 500,
56
+ medium: 900,
57
+ large: 1400
58
+ };
59
+ var defaultTargetLengthWords = targetLengthAliasWordCounts.medium;
60
+ function parseTargetLengthWords(value2) {
61
+ if (typeof value2 === "number") {
62
+ return Number.isInteger(value2) && value2 > 0 ? value2 : void 0;
63
+ }
64
+ if (typeof value2 !== "string") {
65
+ return void 0;
66
+ }
67
+ const normalized = value2.trim().toLowerCase();
68
+ if (normalized.length === 0) {
69
+ return void 0;
70
+ }
71
+ if (targetLengthValues.includes(normalized)) {
72
+ return targetLengthAliasWordCounts[normalized];
73
+ }
74
+ if (!/^\d+$/.test(normalized)) {
75
+ return void 0;
76
+ }
77
+ const parsed = Number.parseInt(normalized, 10);
78
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
79
+ }
80
+ var targetLengthWordsSchema = z.preprocess(
81
+ (value2) => parseTargetLengthWords(value2),
82
+ z.number().int().positive()
83
+ );
84
+ function resolveTargetLengthAlias(targetLengthWords) {
85
+ if (!Number.isFinite(targetLengthWords) || targetLengthWords <= 0) {
86
+ return "medium";
87
+ }
88
+ if (targetLengthWords <= 700) {
89
+ return "small";
90
+ }
91
+ if (targetLengthWords <= 1150) {
92
+ return "medium";
93
+ }
94
+ return "large";
95
+ }
24
96
  var contentTargetRoleValues = ["primary", "secondary"];
25
97
  var contentTargetSchema = z.object({
26
98
  contentType: z.enum(contentTypeValues),
@@ -51,7 +123,8 @@ var appSettingsSchema = z.object({
51
123
  message: "contentTargets must include exactly one primary target."
52
124
  }).default([{ contentType: "article", role: "primary", count: 1 }]),
53
125
  style: z.enum(writingStyleValues).default("professional"),
54
- targetLength: z.enum(targetLengthValues).default("medium")
126
+ intent: z.enum(contentIntentValues).default("tutorial"),
127
+ targetLength: targetLengthWordsSchema.default(defaultTargetLengthWords)
55
128
  });
56
129
  var envSettingsSchema = z.object({
57
130
  openRouterApiKey: z.string().optional(),
@@ -66,7 +139,8 @@ var envSettingsSchema = z.object({
66
139
  markdownOutputDir: z.string().optional(),
67
140
  assetOutputDir: z.string().optional(),
68
141
  style: z.enum(writingStyleValues).optional(),
69
- targetLength: z.enum(targetLengthValues).optional()
142
+ intent: z.enum(contentIntentValues).optional(),
143
+ targetLength: targetLengthWordsSchema.optional()
70
144
  });
71
145
  var jobInputSchema = z.object({
72
146
  idea: z.string().min(1).optional(),
@@ -111,6 +185,7 @@ function readEnvSettings(env = process.env) {
111
185
  markdownOutputDir: env.IDEON_MARKDOWN_OUTPUT_DIR,
112
186
  assetOutputDir: env.IDEON_ASSET_OUTPUT_DIR,
113
187
  style: env.IDEON_STYLE,
188
+ intent: env.IDEON_INTENT,
114
189
  targetLength: env.IDEON_TARGET_LENGTH
115
190
  });
116
191
  }
@@ -179,6 +254,37 @@ function buildGenerationDirectoryName(baseSlug, now = /* @__PURE__ */ new Date()
179
254
  ].join("");
180
255
  return `${stamp}-${baseSlug}`;
181
256
  }
257
+ async function listMarkdownFilesRecursively(rootDir) {
258
+ return listFilesRecursively(rootDir, (fileName) => fileName.toLowerCase().endsWith(".md"));
259
+ }
260
+ async function listFilesRecursively(rootDir, predicate) {
261
+ const fs = await import("fs/promises");
262
+ const results = [];
263
+ const stack = [rootDir];
264
+ while (stack.length > 0) {
265
+ const current = stack.pop();
266
+ if (!current) {
267
+ continue;
268
+ }
269
+ let entries;
270
+ try {
271
+ entries = await fs.readdir(current, { withFileTypes: true });
272
+ } catch {
273
+ continue;
274
+ }
275
+ for (const entry of entries) {
276
+ const fullPath = path2.join(current, entry.name);
277
+ if (entry.isDirectory()) {
278
+ stack.push(fullPath);
279
+ continue;
280
+ }
281
+ if (entry.isFile() && predicate(entry.name)) {
282
+ results.push(fullPath);
283
+ }
284
+ }
285
+ }
286
+ return results;
287
+ }
182
288
  async function writeUtf8File(filePath, content) {
183
289
  await mkdir2(path2.dirname(filePath), { recursive: true });
184
290
  await writeFile2(filePath, content, "utf8");
@@ -637,6 +743,7 @@ var configSettingKeys = [
637
743
  "markdownOutputDir",
638
744
  "assetOutputDir",
639
745
  "style",
746
+ "intent",
640
747
  "targetLength"
641
748
  ];
642
749
  var configSecretKeys = ["openRouterApiKey", "replicateApiToken"];
@@ -665,6 +772,7 @@ async function configList() {
665
772
  markdownOutputDir: settings.markdownOutputDir,
666
773
  assetOutputDir: settings.assetOutputDir,
667
774
  style: settings.style,
775
+ intent: settings.intent,
668
776
  targetLength: settings.targetLength
669
777
  },
670
778
  secrets: {
@@ -767,12 +875,23 @@ function coerceSettingValue(key, rawValue) {
767
875
  }
768
876
  return trimmed;
769
877
  }
770
- case "targetLength": {
771
- if (!targetLengthValues.includes(trimmed)) {
772
- throw new Error(`targetLength must be one of: ${targetLengthValues.join(", ")}.`);
878
+ case "intent": {
879
+ if (!contentIntentValues.includes(trimmed)) {
880
+ throw new Error(`intent must be one of: ${contentIntentValues.join(", ")}.`);
773
881
  }
774
882
  return trimmed;
775
883
  }
884
+ case "targetLength": {
885
+ const normalized = trimmed.toLowerCase();
886
+ if (targetLengthValues.includes(normalized)) {
887
+ return normalized;
888
+ }
889
+ const parsed = Number.parseInt(trimmed, 10);
890
+ if (!Number.isFinite(parsed) || parsed <= 0) {
891
+ throw new Error(`targetLength must be one of: ${targetLengthValues.join(", ")}, or a positive integer word count.`);
892
+ }
893
+ return parsed;
894
+ }
776
895
  default:
777
896
  throw new Error(`Unsupported config key: ${key}`);
778
897
  }
@@ -797,6 +916,8 @@ function getSettingValue(settings, key) {
797
916
  return settings.assetOutputDir;
798
917
  case "style":
799
918
  return settings.style;
919
+ case "intent":
920
+ return settings.intent;
800
921
  case "targetLength":
801
922
  return settings.targetLength;
802
923
  default:
@@ -823,6 +944,8 @@ function setSettingValue(settings, key, value2) {
823
944
  return { ...settings, assetOutputDir: value2 };
824
945
  case "style":
825
946
  return { ...settings, style: value2 };
947
+ case "intent":
948
+ return { ...settings, intent: value2 };
826
949
  case "targetLength":
827
950
  return { ...settings, targetLength: value2 };
828
951
  default:
@@ -840,7 +963,8 @@ var writeToolInputSchema = {
840
963
  primary: z3.string().optional(),
841
964
  secondary: z3.array(z3.string()).optional(),
842
965
  style: z3.enum(writingStyleValues).optional(),
843
- length: z3.enum(targetLengthValues).optional(),
966
+ intent: z3.enum(contentIntentValues).optional(),
967
+ length: z3.union([z3.enum(targetLengthValues), z3.coerce.number().int().positive()]).optional(),
844
968
  dryRun: z3.boolean().optional(),
845
969
  enrichLinks: z3.boolean().optional()
846
970
  };
@@ -869,6 +993,7 @@ var ideonToolContracts = [
869
993
  required: ["idea"],
870
994
  enums: {
871
995
  style: [...writingStyleValues],
996
+ intent: [...contentIntentValues],
872
997
  length: [...targetLengthValues]
873
998
  }
874
999
  },
@@ -899,6 +1024,7 @@ var ideonSkillRegistry = [
899
1024
  required: ["idea"],
900
1025
  enums: {
901
1026
  style: [...writingStyleValues],
1027
+ intent: [...contentIntentValues],
902
1028
  length: [...targetLengthValues]
903
1029
  }
904
1030
  }
@@ -959,6 +1085,18 @@ function validateIntegrationContracts(sources = {
959
1085
  [...writeSkill.inputContract.enums.style ?? []].sort(),
960
1086
  [...writingStyleValues].sort()
961
1087
  );
1088
+ compareStringArrays(
1089
+ drifts,
1090
+ "write.enum.intent.tool-vs-schema",
1091
+ [...writeTool.enums.intent ?? []].sort(),
1092
+ [...contentIntentValues].sort()
1093
+ );
1094
+ compareStringArrays(
1095
+ drifts,
1096
+ "write.enum.intent.skill-vs-schema",
1097
+ [...writeSkill.inputContract.enums.intent ?? []].sort(),
1098
+ [...contentIntentValues].sort()
1099
+ );
962
1100
  compareStringArrays(
963
1101
  drifts,
964
1102
  "write.enum.length.tool-vs-schema",
@@ -1161,7 +1299,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1161
1299
  // package.json
1162
1300
  var package_default = {
1163
1301
  name: "@telepat/ideon",
1164
- version: "0.1.7",
1302
+ version: "0.1.13",
1165
1303
  description: "CLI for generating rich articles and images from ideas.",
1166
1304
  type: "module",
1167
1305
  repository: {
@@ -1301,8 +1439,10 @@ async function resolveRunInput(input) {
1301
1439
  ...envSettings.markdownOutputDir ? { markdownOutputDir: envSettings.markdownOutputDir } : {},
1302
1440
  ...envSettings.assetOutputDir ? { assetOutputDir: envSettings.assetOutputDir } : {},
1303
1441
  ...envSettings.style ? { style: envSettings.style } : {},
1442
+ ...envSettings.intent ? { intent: envSettings.intent } : {},
1304
1443
  ...envSettings.targetLength ? { targetLength: envSettings.targetLength } : {},
1305
1444
  ...input.style ? { style: input.style } : {},
1445
+ ...input.intent ? { intent: input.intent } : {},
1306
1446
  ...input.targetLength ? { targetLength: input.targetLength } : {},
1307
1447
  ...input.contentTargets ? { contentTargets: input.contentTargets } : {}
1308
1448
  });
@@ -1362,7 +1502,7 @@ function assertNoLegacyXMode(contentTargets, sourceLabel) {
1362
1502
  // src/pipeline/runner.ts
1363
1503
  import { mkdir as mkdir5, stat as stat2 } from "fs/promises";
1364
1504
  import { randomUUID } from "crypto";
1365
- import path7 from "path";
1505
+ import path8 from "path";
1366
1506
 
1367
1507
  // src/generation/enrichLinks.ts
1368
1508
  import { readFile as readFile4 } from "fs/promises";
@@ -1669,44 +1809,6 @@ function toExpressionPreview(expression, maxLength = 60) {
1669
1809
  }
1670
1810
 
1671
1811
  // src/llm/prompts/writingFramework.ts
1672
- var BASE_WRITING_FRAMEWORK = [
1673
- "Writing framework:",
1674
- "Structure with intent: open with a clear hook, build ideas in a logical progression, and close with a concrete takeaway.",
1675
- "Information density mandate: each sentence must add new value, mechanism, evidence, or action; avoid empty recap lines.",
1676
- "Specificity over vagueness: use concrete details, named mechanisms, and practical examples instead of abstract filler.",
1677
- "Rhythm and readability: vary sentence length with short, medium, and occasional longer lines to avoid monotony and keep pace.",
1678
- "Scannability and signposting: make section flow obvious with strong headings, parallel list structure, and clear paragraph openings.",
1679
- "Active voice and concrete subjects: make the actor explicit and keep claims verifiable.",
1680
- "Story discipline: use narrative only when it clarifies the idea, and tie every story beat to reader outcome.",
1681
- "Channel fit: match native conventions of the target format while preserving clarity and substance.",
1682
- "Authenticity filter: prefer plain professional language over polished AI-sounding phrasing or generic corporate jargon."
1683
- ].join(" ");
1684
- var DO_AVOID_EXAMPLES = [
1685
- "Do examples:",
1686
- 'Do write concrete guidance such as "Use a 3-step rollout checklist with owner, deadline, and acceptance signal".',
1687
- 'Do write a precise hook such as "Most teams lose two weeks per launch because approvals have no clear owner".',
1688
- "Do make outcomes measurable with numbers, constraints, or operational tradeoffs when possible.",
1689
- "Avoid examples:",
1690
- 'Avoid generic lines such as "In todays world, innovation is important".',
1691
- 'Avoid empty claims such as "This strategy changes everything" without evidence or mechanism.',
1692
- "Avoid over-polished transitions and dramatic cliches when a simple connector is clearer.",
1693
- "Avoid ending paragraphs with summary-only filler that adds no new information."
1694
- ].join(" ");
1695
- var STYLE_DIRECTIVES = {
1696
- professional: "Style directive (professional): use crisp, confident language, balanced tone, and decision-ready framing. Favor precise terms, low hype, and explicit constraints.",
1697
- friendly: "Style directive (friendly): use warm, conversational language, simple transitions, and approachable phrasing. Use natural contractions and short punchy lines without losing specificity.",
1698
- technical: "Style directive (technical): prioritize precision, explicit terminology, and implementation-level clarity. Preserve canonical technical terms, avoid unnecessary synonyms, and state assumptions directly.",
1699
- academic: "Style directive (academic): use formal tone, careful qualification, and analytical structure. Distinguish evidence from inference and avoid rhetorical overstatement.",
1700
- opinionated: "Style directive (opinionated): take a clear stance, defend it with reasoning, and avoid hedging. Make tradeoffs explicit and support claims with concrete examples.",
1701
- storytelling: "Style directive (storytelling): foreground scene and momentum, then extract practical insight at each turn. Use sensory or situational detail sparingly and always tie it to utility."
1702
- };
1703
- var FALLBACK_STYLE_DIRECTIVE = "Style directive: keep tone consistent, intentional, and aligned with requested audience and channel. Prefer specific, active, and concrete language over generic polish.";
1704
- function buildWritingFrameworkInstruction() {
1705
- return [BASE_WRITING_FRAMEWORK, DO_AVOID_EXAMPLES].join(" ");
1706
- }
1707
- function buildStyleDirective(style) {
1708
- return STYLE_DIRECTIVES[style] ?? FALLBACK_STYLE_DIRECTIVE;
1709
- }
1710
1812
  function buildRunContextDirective(contentTypes) {
1711
1813
  const normalizedTypes = contentTypes.length > 0 ? contentTypes.join(", ") : "article";
1712
1814
  return `Run context: requested content types are ${normalizedTypes}. Keep output aligned with this distribution plan, maintain one shared content brief, and adapt structure per channel without duplicating article-only scaffolding.`;
@@ -1716,42 +1818,148 @@ var TARGET_LENGTH_TIERS = {
1716
1818
  label: "small",
1717
1819
  article: "Target length (small article): 300\u2013800 words total, 2\u20134 sections, ~2\u20133 paragraphs per section. One core idea, lightly explored. Minimal storytelling. Use for quick explainers.",
1718
1820
  "blog-post": "Target length (small blog post): 500\u2013900 words. Answer-focused, minimal fluff, straight to value. Good for long-tail SEO and short how-to queries.",
1719
- "x-thread": "Target length (small x-thread): 3\u20135 posts, each post one clear idea with momentum from one step to the next.",
1720
- "x-post": "Target length (small x-post): 70\u2013150 characters, one idea, pure hook or insight.",
1721
- "reddit-post": "Target length (small reddit post): 150\u2013400 words. Quick question or observation, minimal formatting.",
1722
1821
  "linkedin-post": "Target length (small linkedin post): 50\u2013150 words, 3\u20136 lines, single insight.",
1723
1822
  newsletter: "Target length (small newsletter): 300\u2013800 words, 1\u20132 sections, one core idea.",
1724
- "landing-page-copy": "Target length (small landing page): 150\u2013400 words total. Sections: headline, subhead, CTA only.",
1823
+ "press-release": "Target length (small press release): 300\u2013700 words with headline, lead, core announcement details, and concise quote block.",
1824
+ "reddit-post": "Target length (small reddit post): 150\u2013400 words. Quick question or observation, minimal formatting.",
1825
+ "science-paper": "Target length (small science paper): 800\u20131,400 words condensed structure with abstract-style opener, methods summary, and key findings.",
1826
+ "x-post": "Target length (small x-post): 70\u2013150 characters, one idea, pure hook or insight.",
1827
+ "x-thread": "Target length (small x-thread): 3\u20135 posts, each post one clear idea with momentum from one step to the next.",
1725
1828
  fallback: "Target length (small): 50\u2013300 words. Compressed insight density. Prioritise hooks and key points over elaboration."
1726
1829
  },
1727
1830
  medium: {
1728
1831
  label: "medium",
1729
1832
  article: "Target length (medium article): 800\u20131,800 words total, 4\u20136 sections, ~3\u20135 paragraphs per section. 2\u20133 core ideas with examples and light narrative. Default best-performing size.",
1730
1833
  "blog-post": "Target length (medium blog post): 900\u20131,800 words. Structured with clear H2s, includes examples and takeaways. Sweet spot for SEO and readability.",
1731
- "x-thread": "Target length (medium x-thread): 5\u20138 posts, each post concise and additive, with clear narrative progression.",
1732
- "x-post": "Target length (medium x-post): 150\u2013280 characters or 2\u20134 short lines, 1\u20132 ideas with slight expansion.",
1733
- "reddit-post": "Target length (medium reddit post): 400\u20131,200 words. Context, experience, and question. Conversational tone.",
1734
1834
  "linkedin-post": "Target length (medium linkedin post): 150\u2013400 words, 8\u201315 short lines, story plus takeaway. Best performing range.",
1735
1835
  newsletter: "Target length (medium newsletter): 800\u20131,800 words, 2\u20134 sections, curated insights. Ideal default.",
1736
- "landing-page-copy": "Target length (medium landing page): 400\u2013900 words. Sections: hero, benefits, social proof, CTA.",
1836
+ "press-release": "Target length (medium press release): 700\u20131,200 words with complete release anatomy, context, quote, and next-step details.",
1837
+ "reddit-post": "Target length (medium reddit post): 400\u20131,200 words. Context, experience, and question. Conversational tone.",
1838
+ "science-paper": "Target length (medium science paper): 1,400\u20132,600 words with clearer methodological depth, results framing, and discussion of limitations.",
1839
+ "x-post": "Target length (medium x-post): 150\u2013280 characters or 2\u20134 short lines, 1\u20132 ideas with slight expansion.",
1840
+ "x-thread": "Target length (medium x-thread): 5\u20138 posts, each post concise and additive, with clear narrative progression.",
1737
1841
  fallback: "Target length (medium): 300\u20131,200 words. Balanced depth and breadth with examples and actionable takeaways."
1738
1842
  },
1739
1843
  large: {
1740
1844
  label: "large",
1741
1845
  article: "Target length (large article): 1,800\u20133,500+ words total, 6\u201310 sections, ~5\u20138 paragraphs per section. Deep exploration with frameworks, strong internal linking potential. Use for SEO authority and pillar content.",
1742
1846
  "blog-post": "Target length (large blog post): 1,800\u20133,000 words. Comprehensive coverage with FAQs, examples, and edge cases. Use when competing for high-value keywords.",
1743
- "x-thread": "Target length (large x-thread): 8\u201312 posts, each post one punchy idea with strong narrative progression. Every post must independently hook.",
1744
- "x-post": "Target length (large x-post): 200\u2013300 characters, one strong stance plus one supporting detail or proof.",
1745
- "reddit-post": "Target length (large reddit post): 1,200\u20132,500+ words. Detailed breakdown with story, lessons, numbers, and mistakes.",
1746
1847
  "linkedin-post": "Target length (large linkedin post): 400\u2013900 words. Structured storytelling, multiple insights. Use sparingly for deep authority posts.",
1747
1848
  newsletter: "Target length (large newsletter): 1,800\u20133,000 words. Multi-topic edition with deep commentary. Use for weekly deep dives.",
1748
- "landing-page-copy": "Target length (large landing page): 900\u20132,000+ words. Includes objection handling, FAQs, detailed benefits, and case studies.",
1849
+ "press-release": "Target length (large press release): 1,200\u20132,000+ words with full context, expanded quote material, and detailed release implications.",
1850
+ "reddit-post": "Target length (large reddit post): 1,200\u20132,500+ words. Detailed breakdown with story, lessons, numbers, and mistakes.",
1851
+ "science-paper": "Target length (large science paper): 2,600\u20134,500+ words with full narrative arc from research question through methods, results, and implications.",
1852
+ "x-post": "Target length (large x-post): 200\u2013300 characters, one strong stance plus one supporting detail or proof.",
1853
+ "x-thread": "Target length (large x-thread): 8\u201312 posts, each post one punchy idea with strong narrative progression. Every post must independently hook.",
1749
1854
  fallback: "Target length (large): 1,200\u20133,500+ words. Deep exploration with frameworks, multiple examples, and expanded narrative."
1750
1855
  }
1751
1856
  };
1752
- function buildTargetLengthDirective(contentType, targetLength) {
1753
- const tier = TARGET_LENGTH_TIERS[targetLength] ?? TARGET_LENGTH_TIERS["medium"];
1754
- return tier[contentType] ?? tier.fallback;
1857
+ function buildTargetLengthDirective(contentType, targetLengthWords) {
1858
+ const normalizedTargetLengthWords = Number.isFinite(targetLengthWords) && targetLengthWords > 0 ? Math.round(targetLengthWords) : 900;
1859
+ const alias = resolveTargetLengthAlias(normalizedTargetLengthWords);
1860
+ const tier = TARGET_LENGTH_TIERS[alias] ?? TARGET_LENGTH_TIERS["medium"];
1861
+ if (contentType === "article") {
1862
+ return `Target length (article): aim for about ${normalizedTargetLengthWords} words total while keeping section depth and structure consistent.`;
1863
+ }
1864
+ const baseDirective = tier[contentType] ?? tier.fallback;
1865
+ return `${baseDirective} Overall run target is about ${normalizedTargetLengthWords} words.`;
1866
+ }
1867
+
1868
+ // src/llm/prompts/guideBundles.ts
1869
+ import { existsSync, readFileSync } from "fs";
1870
+ import path5 from "path";
1871
+ var guideCache = /* @__PURE__ */ new Map();
1872
+ function normalizeGuideContent(content) {
1873
+ return content.replace(/\r\n/g, "\n").trim();
1874
+ }
1875
+ function readGuideFile(relativePath) {
1876
+ const cached = guideCache.get(relativePath);
1877
+ if (cached) {
1878
+ return cached;
1879
+ }
1880
+ const absolutePath = path5.resolve(process.cwd(), relativePath);
1881
+ if (!existsSync(absolutePath)) {
1882
+ const fallback = `Guide unavailable: ${relativePath}. Continue with the remaining guidance.`;
1883
+ guideCache.set(relativePath, fallback);
1884
+ return fallback;
1885
+ }
1886
+ try {
1887
+ const content = normalizeGuideContent(readFileSync(absolutePath, "utf8"));
1888
+ guideCache.set(relativePath, content);
1889
+ return content;
1890
+ } catch {
1891
+ const fallback = `Guide failed to load: ${relativePath}. Continue with the remaining guidance.`;
1892
+ guideCache.set(relativePath, fallback);
1893
+ return fallback;
1894
+ }
1895
+ }
1896
+ function buildGuideSection(relativePath) {
1897
+ const content = readGuideFile(relativePath);
1898
+ return [
1899
+ `Guide source: ${relativePath}`,
1900
+ content
1901
+ ].join("\n");
1902
+ }
1903
+ function formatToGuidePath(contentType) {
1904
+ return `writing-guide/formats/${contentType}.md`;
1905
+ }
1906
+ function intentToGuidePath(intent) {
1907
+ return `writing-guide/content-intent/${intent}.md`;
1908
+ }
1909
+ function styleToGuidePath(style) {
1910
+ return `writing-guide/styles/${style}.md`;
1911
+ }
1912
+ function dedupe(items) {
1913
+ return Array.from(new Set(items));
1914
+ }
1915
+ function buildGuideBundle(relativePaths) {
1916
+ const blocks = dedupe(relativePaths).map((relativePath) => buildGuideSection(relativePath));
1917
+ return [
1918
+ "External writing guides (apply these rules directly):",
1919
+ ...blocks
1920
+ ].join("\n\n");
1921
+ }
1922
+ function buildArticlePlanGuideInstruction(intent, contentType) {
1923
+ return buildGuideBundle([
1924
+ "writing-guide/references/headline-writing-systems.md",
1925
+ "writing-guide/references/ideation-and-credibility-systems.md",
1926
+ "writing-guide/references/content-frameworks.md",
1927
+ intentToGuidePath(intent),
1928
+ formatToGuidePath(contentType)
1929
+ ]);
1930
+ }
1931
+ function buildArticleSectionGuideInstruction(style, intent, contentType) {
1932
+ return buildGuideBundle([
1933
+ "writing-guide/general/core-web-writing-rules.md",
1934
+ "writing-guide/references/emotional-resonance.md",
1935
+ "writing-guide/references/prose-quality-checks.md",
1936
+ "writing-guide/references/readability-and-pace.md",
1937
+ "writing-guide/references/skimmability-patterns.md",
1938
+ styleToGuidePath(style),
1939
+ intentToGuidePath(intent),
1940
+ formatToGuidePath(contentType)
1941
+ ]);
1942
+ }
1943
+ function buildContentBriefGuideInstruction(intent, primaryContentType, secondaryContentTypes) {
1944
+ return buildGuideBundle([
1945
+ "writing-guide/references/multi-channel-brief-strategy.md",
1946
+ "writing-guide/references/content-frameworks.md",
1947
+ "writing-guide/references/target-length-guidance.md",
1948
+ intentToGuidePath(intent),
1949
+ formatToGuidePath(primaryContentType),
1950
+ ...secondaryContentTypes.map((contentType) => formatToGuidePath(contentType))
1951
+ ]);
1952
+ }
1953
+ function buildChannelContentGuideInstruction(style, intent, contentType) {
1954
+ const conditionalGuides = contentType === "x-thread" ? ["writing-guide/references/x-thread-hooks.md"] : [];
1955
+ return buildGuideBundle([
1956
+ "writing-guide/references/truthful-value-framing.md",
1957
+ "writing-guide/references/target-length-guidance.md",
1958
+ ...conditionalGuides,
1959
+ styleToGuidePath(style),
1960
+ intentToGuidePath(intent),
1961
+ formatToGuidePath(contentType)
1962
+ ]);
1755
1963
  }
1756
1964
 
1757
1965
  // src/llm/prompts/contentBrief.ts
@@ -1791,11 +1999,11 @@ var contentBriefSchema = {
1791
1999
  };
1792
2000
  function buildContentBriefMessages(idea, options) {
1793
2001
  const audienceSeed = options.targetAudienceHint?.trim() || "A general, non-specific audience.";
2002
+ const hasSecondaryContentTypes = options.secondaryContentTypes.length > 0;
1794
2003
  const systemInstruction = [
1795
2004
  "You are a senior editorial strategist.",
1796
2005
  "Produce a shared content brief that can guide all requested content types in this run.",
1797
- buildWritingFrameworkInstruction(),
1798
- buildStyleDirective(options.style),
2006
+ buildContentBriefGuideInstruction(options.intent, options.primaryContentType, options.secondaryContentTypes),
1799
2007
  buildRunContextDirective([options.primaryContentType, ...options.secondaryContentTypes]),
1800
2008
  "The brief must be specific, concrete, and directly usable by writers without extra clarification.",
1801
2009
  "This run has one explicit primary output and optional secondary outputs that should promote or incite interest in the primary while remaining independently valuable.",
@@ -1824,7 +2032,7 @@ function buildContentBriefMessages(idea, options) {
1824
2032
  "- voiceNotes: practical tone/voice constraints to keep outputs consistent.",
1825
2033
  `- primaryContentType: set to "${options.primaryContentType}" exactly.`,
1826
2034
  `- secondaryContentTypes: include these types exactly: ${options.secondaryContentTypes.join(", ") || "none"}.`,
1827
- "- secondaryContentStrategy: explicit guidance for making secondary outputs channel-native, self-contained, and enticing gateways into the primary content.",
2035
+ hasSecondaryContentTypes ? "- secondaryContentStrategy: explicit guidance for making secondary outputs channel-native, self-contained, and enticing gateways into the primary content." : "- secondaryContentStrategy: set to an empty string because this run has no secondary outputs.",
1828
2036
  "",
1829
2037
  "Return JSON only with all required fields."
1830
2038
  ].join("\n")
@@ -1834,6 +2042,15 @@ function buildContentBriefMessages(idea, options) {
1834
2042
 
1835
2043
  // src/types/contentBriefSchema.ts
1836
2044
  import { z as z4 } from "zod";
2045
+ var secondaryTypeSentinelValues = /* @__PURE__ */ new Set([
2046
+ "none",
2047
+ "n/a",
2048
+ "na",
2049
+ "null",
2050
+ "not applicable",
2051
+ "no secondary content",
2052
+ "no secondary outputs"
2053
+ ]);
1837
2054
  var contentBriefSchema2 = z4.object({
1838
2055
  title: z4.string().min(8),
1839
2056
  description: z4.string().min(40),
@@ -1842,8 +2059,24 @@ var contentBriefSchema2 = z4.object({
1842
2059
  keyPoints: z4.array(z4.string().min(8)).min(3).max(6),
1843
2060
  voiceNotes: z4.string().min(20),
1844
2061
  primaryContentType: z4.string().min(2),
1845
- secondaryContentTypes: z4.array(z4.string().min(2)).max(10),
1846
- secondaryContentStrategy: z4.string().min(20)
2062
+ secondaryContentTypes: z4.array(z4.string().min(2)).max(10).transform((values) => values.map((value2) => value2.trim()).filter((value2) => value2.length > 0).filter((value2) => !secondaryTypeSentinelValues.has(value2.toLowerCase()))),
2063
+ secondaryContentStrategy: z4.string()
2064
+ }).superRefine((brief, ctx) => {
2065
+ const hasSecondaryTargets = brief.secondaryContentTypes.length > 0;
2066
+ if (!hasSecondaryTargets) {
2067
+ return;
2068
+ }
2069
+ if (brief.secondaryContentStrategy.trim().length < 20) {
2070
+ ctx.addIssue({
2071
+ code: z4.ZodIssueCode.too_small,
2072
+ minimum: 20,
2073
+ inclusive: true,
2074
+ origin: "string",
2075
+ path: ["secondaryContentStrategy"],
2076
+ type: "string",
2077
+ message: "Too small: expected string to have >=20 characters"
2078
+ });
2079
+ }
1847
2080
  });
1848
2081
 
1849
2082
  // src/generation/planContentBrief.ts
@@ -1871,7 +2104,7 @@ async function planContentBrief({
1871
2104
  schemaName: "content_brief",
1872
2105
  schema: contentBriefSchema,
1873
2106
  messages: buildContentBriefMessages(idea, {
1874
- style: settings.style,
2107
+ intent: settings.intent,
1875
2108
  targetAudienceHint,
1876
2109
  primaryContentType: settings.contentTargets.find((target) => target.role === "primary")?.contentType ?? "article",
1877
2110
  secondaryContentTypes: settings.contentTargets.filter((target) => target.role === "secondary").map((target) => target.contentType)
@@ -1916,13 +2149,19 @@ function deriveTitleFromIdea(idea) {
1916
2149
  }
1917
2150
 
1918
2151
  // src/llm/prompts/articlePlan.ts
1919
- var ARTICLE_SECTION_COUNTS = {
1920
- small: { min: 2, max: 4, label: "2 to 4" },
1921
- medium: { min: 4, max: 7, label: "4 to 6" },
1922
- large: { min: 6, max: 10, label: "6 to 10" }
1923
- };
1924
- function buildArticlePlanJsonSchema(targetLength) {
1925
- const sectionCounts = ARTICLE_SECTION_COUNTS[targetLength] ?? ARTICLE_SECTION_COUNTS["medium"];
2152
+ function deriveArticleSectionCounts(targetLengthWords) {
2153
+ const normalizedWords = Number.isFinite(targetLengthWords) && targetLengthWords > 0 ? targetLengthWords : 900;
2154
+ const center = Math.max(2, Math.min(10, Math.round(normalizedWords / 220)));
2155
+ const min = Math.max(2, center - 1);
2156
+ const max = Math.min(10, center + 1);
2157
+ return {
2158
+ min,
2159
+ max,
2160
+ label: `${min} to ${max}`
2161
+ };
2162
+ }
2163
+ function buildArticlePlanJsonSchema(targetLengthWords) {
2164
+ const sectionCounts = deriveArticleSectionCounts(targetLengthWords);
1926
2165
  return {
1927
2166
  type: "object",
1928
2167
  additionalProperties: false,
@@ -1984,16 +2223,12 @@ function buildArticlePlanJsonSchema(targetLength) {
1984
2223
  };
1985
2224
  }
1986
2225
  function buildArticlePlanMessages(idea, options) {
1987
- const sectionCounts = ARTICLE_SECTION_COUNTS[options.targetLength] ?? ARTICLE_SECTION_COUNTS["medium"];
2226
+ const sectionCounts = deriveArticleSectionCounts(options.targetLength);
1988
2227
  const systemInstruction = [
1989
2228
  "You are a senior editorial strategist. Produce a rigorous article plan for a polished long-form Markdown article.",
1990
- buildWritingFrameworkInstruction(),
1991
- buildStyleDirective(options.style),
2229
+ buildArticlePlanGuideInstruction(options.intent, "article"),
1992
2230
  buildRunContextDirective(options.contentTypes),
1993
2231
  buildTargetLengthDirective("article", options.targetLength),
1994
- "Quality bar: produce expert-level structure with high information density, concrete mechanisms, and practical reader outcomes.",
1995
- "Choose an adaptive persuasion structure (AIDA, PAS, or BAB) based on audience need, search intent, and the job-to-be-done of the idea.",
1996
- "Avoid generic filler, empty wrap-up sentences, and vague claims that do not specify how or why.",
1997
2232
  "Return only the requested JSON."
1998
2233
  ].join(" ");
1999
2234
  return [
@@ -2011,7 +2246,7 @@ function buildArticlePlanMessages(idea, options) {
2011
2246
  "- The article should feel authoritative, practical, and clearly structured for scanning and deep reading.",
2012
2247
  "- Generate a memorable title and a sharp subtitle that promise a concrete benefit, mechanism, or outcome.",
2013
2248
  "- The slug must be lowercase kebab-case and publication-ready.",
2014
- "- The description should work as a concise meta description, align with the shared content brief, and avoid hype language.",
2249
+ "- The description should work as a concise meta description and align with the shared content brief.",
2015
2250
  `- Plan ${sectionCounts.label} strong sections with distinct focus areas and logical progression (no repetitive section intent).`,
2016
2251
  "- Frame section titles to reflect likely search intent or practical reader questions when appropriate.",
2017
2252
  "- Each section description should name the mechanism, evidence type, or practical action that makes the section useful.",
@@ -2019,7 +2254,6 @@ function buildArticlePlanMessages(idea, options) {
2019
2254
  "- Include a cover image description and 2 to 3 inline image descriptions.",
2020
2255
  "- Image descriptions must be concrete and contextual, not generic stock-photo phrasing.",
2021
2256
  "- Inline images should be anchored after specific sections using 1-based indexes.",
2022
- "- Avoid AI giveaway phrasing, dramatic cliches, and generic conclusions that add no new information.",
2023
2257
  "",
2024
2258
  "Shared content brief context:",
2025
2259
  `- description: ${options.contentBrief.description}`,
@@ -2087,7 +2321,7 @@ async function planArticle({
2087
2321
  schemaName: "article_plan",
2088
2322
  schema: buildArticlePlanJsonSchema(settings.targetLength),
2089
2323
  messages: buildArticlePlanMessages(idea, {
2090
- style: settings.style,
2324
+ intent: settings.intent,
2091
2325
  contentTypes: settings.contentTargets.map((target) => target.contentType),
2092
2326
  contentBrief,
2093
2327
  targetLength: settings.targetLength
@@ -2162,40 +2396,17 @@ function slugify(value2) {
2162
2396
  }
2163
2397
 
2164
2398
  // src/llm/prompts/channelContent.ts
2165
- var CHANNEL_RULES = {
2166
- "blog-post": [
2167
- "Write a complete Markdown blog post with a clear title, short lead, scannable subheadings, and practical takeaways.",
2168
- "Favor concrete examples, compact paragraphs, and actionable guidance over theory."
2169
- ].join(" "),
2170
- "x-thread": [
2171
- "Write native X thread content with short lines, high signal, and a strong hook in the first line.",
2172
- 'Return a numbered thread with one post per line prefixed like "1/7".',
2173
- "Each thread line must be self-contained but still advance the same core narrative."
2174
- ].join(" "),
2175
- "x-post": [
2176
- "Write native X content with short lines, high signal, and a strong hook in the first line.",
2177
- "Return one concise post only. Do not return numbered thread lines."
2178
- ].join(" "),
2179
- "reddit-post": [
2180
- "Write a Reddit-native post in plain, authentic voice with practical detail and no marketing gloss.",
2181
- "Use first-hand framing, candid constraints, and only minimal formatting that improves readability."
2182
- ].join(" "),
2183
- "linkedin-post": [
2184
- "Write a LinkedIn-native post for professional clarity and engagement.",
2185
- "Open with a strong two-line hook, use spaced short paragraphs, and end with one focused reflection or CTA."
2186
- ].join(" "),
2187
- newsletter: [
2188
- "Write a concise newsletter piece with a subject-line-quality opening and clear section flow.",
2189
- "Prioritize practical value density, strong transitions, and sustained reader momentum."
2190
- ].join(" "),
2191
- "landing-page-copy": [
2192
- "Write landing-page copy in Markdown with headline, value proposition, proof-oriented body blocks, objection handling, and clear CTA text.",
2193
- "Keep claims specific, credible, and measurable. Avoid hype language."
2194
- ].join(" "),
2195
- article: "Write a polished Markdown article."
2196
- };
2399
+ function buildOutputShapeConstraint(contentType) {
2400
+ if (contentType === "x-thread") {
2401
+ return 'Return a numbered thread with one post per line prefixed like "1/7".';
2402
+ }
2403
+ if (contentType === "x-post") {
2404
+ return "Return one concise post only. Do not return numbered thread lines.";
2405
+ }
2406
+ return "";
2407
+ }
2197
2408
  function buildSingleShotContentMessages(options) {
2198
- const channelRule = CHANNEL_RULES[options.contentType] ?? "Write channel-native Markdown content.";
2409
+ const outputShapeConstraint = buildOutputShapeConstraint(options.contentType);
2199
2410
  const articleContext = options.articleReferenceMarkdown ? [
2200
2411
  "Reference primary context (use as anchor source, but adapt natively for the requested channel):",
2201
2412
  options.articleReferenceMarkdown
@@ -2213,10 +2424,9 @@ function buildSingleShotContentMessages(options) {
2213
2424
  content: [
2214
2425
  "You are a senior content strategist and copywriter.",
2215
2426
  `Write exactly one ${options.contentType} output.`,
2216
- buildWritingFrameworkInstruction(),
2217
- buildStyleDirective(options.style),
2427
+ buildChannelContentGuideInstruction(options.style, options.intent, options.contentType),
2218
2428
  roleDirective,
2219
- channelRule
2429
+ outputShapeConstraint
2220
2430
  ].join(" ")
2221
2431
  },
2222
2432
  {
@@ -2257,6 +2467,7 @@ async function writeSingleShotContent({
2257
2467
  role = "secondary",
2258
2468
  primaryContentType,
2259
2469
  style,
2470
+ intent,
2260
2471
  outputIndex,
2261
2472
  outputCountForType,
2262
2473
  articleReferenceMarkdown,
@@ -2286,6 +2497,7 @@ async function writeSingleShotContent({
2286
2497
  role,
2287
2498
  primaryContentType,
2288
2499
  style,
2500
+ intent,
2289
2501
  outputIndex,
2290
2502
  outputCountForType,
2291
2503
  contentBrief,
@@ -2333,13 +2545,12 @@ var OUTRO_PARAGRAPH_COUNTS = {
2333
2545
  medium: "2 to 3",
2334
2546
  large: "3 to 5"
2335
2547
  };
2336
- function buildSystemInstruction(base, style, contentTypes, targetLength) {
2548
+ function buildSystemInstruction(base, style, intent, contentTypes, targetLengthWords) {
2337
2549
  return [
2338
2550
  base,
2339
- buildWritingFrameworkInstruction(),
2340
- buildStyleDirective(style),
2551
+ buildArticleSectionGuideInstruction(style, intent, "article"),
2341
2552
  buildRunContextDirective(contentTypes),
2342
- buildTargetLengthDirective("article", targetLength)
2553
+ buildTargetLengthDirective("article", targetLengthWords)
2343
2554
  ].join(" ");
2344
2555
  }
2345
2556
  function sharedPlanContext(plan) {
@@ -2363,14 +2574,16 @@ function sharedDraftContext(articleSoFar) {
2363
2574
  normalized
2364
2575
  ].join("\n");
2365
2576
  }
2366
- function buildIntroMessages(plan, style, contentTypes, targetLength) {
2577
+ function buildIntroMessages(plan, style, intent, contentTypes, targetLengthWords, introTargetWords) {
2367
2578
  const baseSystemInstruction = buildSystemInstruction(
2368
2579
  "You write polished editorial prose for Markdown articles. Return only the prose body with no heading and no code fences.",
2369
2580
  style,
2581
+ intent,
2370
2582
  contentTypes,
2371
- targetLength
2583
+ targetLengthWords
2372
2584
  );
2373
- const paragraphCount = INTRO_PARAGRAPH_COUNTS[targetLength] ?? INTRO_PARAGRAPH_COUNTS["medium"];
2585
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2586
+ const paragraphCount = INTRO_PARAGRAPH_COUNTS[targetLengthAlias] ?? INTRO_PARAGRAPH_COUNTS["medium"];
2374
2587
  return [
2375
2588
  {
2376
2589
  role: "system",
@@ -2384,20 +2597,23 @@ function buildIntroMessages(plan, style, contentTypes, targetLength) {
2384
2597
  `Write the article introduction using this brief: ${plan.introBrief}`,
2385
2598
  "Requirements:",
2386
2599
  `- ${paragraphCount} paragraphs.`,
2600
+ `- Target length: about ${introTargetWords} words.`,
2387
2601
  "- Hook the reader quickly.",
2388
2602
  "- Set up the argument and tone for the rest of the article."
2389
2603
  ].join("\n")
2390
2604
  }
2391
2605
  ];
2392
2606
  }
2393
- function buildSectionMessages(plan, section, articleSoFar, style, contentTypes, targetLength) {
2607
+ function buildSectionMessages(plan, section, articleSoFar, style, intent, contentTypes, targetLengthWords, sectionTargetWords) {
2394
2608
  const baseSystemInstruction = buildSystemInstruction(
2395
2609
  "You write in-depth Markdown article sections. Return only the prose body for the section, with no heading and no code fences.",
2396
2610
  style,
2611
+ intent,
2397
2612
  contentTypes,
2398
- targetLength
2613
+ targetLengthWords
2399
2614
  );
2400
- const paragraphCount = SECTION_PARAGRAPH_COUNTS[targetLength] ?? SECTION_PARAGRAPH_COUNTS["medium"];
2615
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2616
+ const paragraphCount = SECTION_PARAGRAPH_COUNTS[targetLengthAlias] ?? SECTION_PARAGRAPH_COUNTS["medium"];
2401
2617
  return [
2402
2618
  {
2403
2619
  role: "system",
@@ -2414,6 +2630,7 @@ function buildSectionMessages(plan, section, articleSoFar, style, contentTypes,
2414
2630
  `Section focus: ${section.description}`,
2415
2631
  "Requirements:",
2416
2632
  `- ${paragraphCount} paragraphs.`,
2633
+ `- Target length: about ${sectionTargetWords} words.`,
2417
2634
  "- Be concrete and specific.",
2418
2635
  "- Continue naturally from the article draft so far without rehashing prior sections.",
2419
2636
  "- Use short Markdown lists only if they materially improve clarity."
@@ -2421,14 +2638,16 @@ function buildSectionMessages(plan, section, articleSoFar, style, contentTypes,
2421
2638
  }
2422
2639
  ];
2423
2640
  }
2424
- function buildOutroMessages(plan, style, contentTypes, targetLength) {
2641
+ function buildOutroMessages(plan, style, intent, contentTypes, targetLengthWords, outroTargetWords) {
2425
2642
  const baseSystemInstruction = buildSystemInstruction(
2426
2643
  "You write polished editorial conclusions for Markdown articles. Return only the prose body with no heading and no code fences.",
2427
2644
  style,
2645
+ intent,
2428
2646
  contentTypes,
2429
- targetLength
2647
+ targetLengthWords
2430
2648
  );
2431
- const paragraphCount = OUTRO_PARAGRAPH_COUNTS[targetLength] ?? OUTRO_PARAGRAPH_COUNTS["medium"];
2649
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2650
+ const paragraphCount = OUTRO_PARAGRAPH_COUNTS[targetLengthAlias] ?? OUTRO_PARAGRAPH_COUNTS["medium"];
2432
2651
  return [
2433
2652
  {
2434
2653
  role: "system",
@@ -2442,6 +2661,7 @@ function buildOutroMessages(plan, style, contentTypes, targetLength) {
2442
2661
  `Write the article conclusion using this brief: ${plan.outroBrief}`,
2443
2662
  "Requirements:",
2444
2663
  `- ${paragraphCount} paragraphs.`,
2664
+ `- Target length: about ${outroTargetWords} words.`,
2445
2665
  "- Synthesize the main argument.",
2446
2666
  "- End with a strong, thoughtful closing line."
2447
2667
  ].join("\n")
@@ -2459,13 +2679,16 @@ async function writeArticleSections({
2459
2679
  onLlmMetrics,
2460
2680
  onInteraction
2461
2681
  }) {
2682
+ const wordBudgets = allocateWordBudgets(settings.targetLength, plan.sections.length);
2462
2683
  onSectionStart?.("Writing introduction");
2463
2684
  const intro = dryRun || !openRouter ? dryRunIntro(plan) : await openRouter.requestText({
2464
2685
  messages: buildIntroMessages(
2465
2686
  plan,
2466
2687
  settings.style,
2688
+ settings.intent,
2467
2689
  settings.contentTargets.map((target) => target.contentType),
2468
- settings.targetLength
2690
+ settings.targetLength,
2691
+ wordBudgets.intro
2469
2692
  ),
2470
2693
  settings,
2471
2694
  interactionContext: {
@@ -2487,8 +2710,10 @@ async function writeArticleSections({
2487
2710
  section,
2488
2711
  buildArticleSoFarContext(intro, sections),
2489
2712
  settings.style,
2713
+ settings.intent,
2490
2714
  settings.contentTargets.map((target) => target.contentType),
2491
- settings.targetLength
2715
+ settings.targetLength,
2716
+ wordBudgets.sections[index] ?? wordBudgets.sections[wordBudgets.sections.length - 1] ?? 150
2492
2717
  ),
2493
2718
  settings,
2494
2719
  interactionContext: {
@@ -2510,8 +2735,10 @@ async function writeArticleSections({
2510
2735
  messages: buildOutroMessages(
2511
2736
  plan,
2512
2737
  settings.style,
2738
+ settings.intent,
2513
2739
  settings.contentTargets.map((target) => target.contentType),
2514
- settings.targetLength
2740
+ settings.targetLength,
2741
+ wordBudgets.outro
2515
2742
  ),
2516
2743
  settings,
2517
2744
  interactionContext: {
@@ -2547,6 +2774,23 @@ function dryRunOutro(plan) {
2547
2774
  "What matters is a workflow that can repeatedly transform a promising idea into a piece that is clear, useful, and worth reading."
2548
2775
  ].join("\n\n");
2549
2776
  }
2777
+ function allocateWordBudgets(totalTargetWords, sectionCount) {
2778
+ const normalizedTotal = Number.isFinite(totalTargetWords) && totalTargetWords > 0 ? Math.round(totalTargetWords) : 900;
2779
+ const normalizedSectionCount = Math.max(1, sectionCount);
2780
+ const intro = Math.max(80, Math.round(normalizedTotal * 0.15));
2781
+ const outro = Math.max(80, Math.round(normalizedTotal * 0.1));
2782
+ const remainingForSections = Math.max(normalizedSectionCount * 120, normalizedTotal - intro - outro);
2783
+ const baseSectionWords = Math.floor(remainingForSections / normalizedSectionCount);
2784
+ let remainder = remainingForSections - baseSectionWords * normalizedSectionCount;
2785
+ const sections = Array.from({ length: normalizedSectionCount }, () => {
2786
+ const next = baseSectionWords + (remainder > 0 ? 1 : 0);
2787
+ if (remainder > 0) {
2788
+ remainder -= 1;
2789
+ }
2790
+ return Math.max(120, next);
2791
+ });
2792
+ return { intro, sections, outro };
2793
+ }
2550
2794
  function buildArticleSoFarContext(intro, sections) {
2551
2795
  const parts = ["## Introduction", intro.trim()];
2552
2796
  for (const section of sections) {
@@ -2594,6 +2838,14 @@ var ReplicateClient = class {
2594
2838
  const backoff = backoffMs(attempt);
2595
2839
  retries += 1;
2596
2840
  retryBackoffMs += backoff;
2841
+ options.onRetry?.({
2842
+ attempts,
2843
+ retries,
2844
+ retryBackoffMs,
2845
+ backoffMs: backoff,
2846
+ errorMessage: lastError.message,
2847
+ modelId: model
2848
+ });
2597
2849
  await wait(backoff);
2598
2850
  continue;
2599
2851
  }
@@ -2617,7 +2869,7 @@ function wait(ms) {
2617
2869
 
2618
2870
  // src/images/renderImages.ts
2619
2871
  import { writeFile as writeFile4 } from "fs/promises";
2620
- import path5 from "path";
2872
+ import path6 from "path";
2621
2873
 
2622
2874
  // src/llm/prompts/imagePrompt.ts
2623
2875
  var imagePromptSchema = {
@@ -3296,14 +3548,15 @@ async function renderExpandedImages({
3296
3548
  dryRun,
3297
3549
  onProgress,
3298
3550
  onRenderComplete,
3299
- onInteraction
3551
+ onInteraction,
3552
+ onRetry
3300
3553
  }) {
3301
3554
  const renderedImages = [];
3302
3555
  for (let index = 0; index < prompts.length; index += 1) {
3303
3556
  const prompt = prompts[index];
3304
3557
  onProgress?.(`Rendering image ${index + 1}/${prompts.length} with ${settings.t2i.modelId}`);
3305
3558
  const fileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.${resolveOutputFormat(settings)}`;
3306
- const outputPath = path5.join(assetDir, fileName);
3559
+ const outputPath = path6.join(assetDir, fileName);
3307
3560
  if (dryRun || !replicate) {
3308
3561
  const dryRunStartMs = Date.now();
3309
3562
  await writeFile4(outputPath, `Placeholder image for: ${prompt.prompt}
@@ -3361,6 +3614,14 @@ async function renderExpandedImages({
3361
3614
  runAttempts = metrics.attempts;
3362
3615
  runRetries = metrics.retries;
3363
3616
  runRetryBackoffMs = metrics.retryBackoffMs;
3617
+ },
3618
+ onRetry(event) {
3619
+ onRetry?.({
3620
+ imageId: prompt.id,
3621
+ kind: prompt.kind,
3622
+ retries: event.retries,
3623
+ errorMessage: event.errorMessage
3624
+ });
3364
3625
  }
3365
3626
  });
3366
3627
  const bytes = await normalizeReplicateOutput(output);
@@ -4067,7 +4328,7 @@ ${body.join("\n").trim()}
4067
4328
 
4068
4329
  // src/pipeline/sessionStore.ts
4069
4330
  import { mkdir as mkdir4, readFile as readFile5, rm as rm2, writeFile as writeFile5 } from "fs/promises";
4070
- import path6 from "path";
4331
+ import path7 from "path";
4071
4332
  import { z as z6 } from "zod";
4072
4333
  var STAGE_IDS = ["shared-brief", "planning", "sections", "image-prompts", "images", "output", "links"];
4073
4334
  var generatedArticleSectionSchema = z6.object({
@@ -4143,10 +4404,10 @@ var writeSessionStateSchema = z6.object({
4143
4404
  artifact: pipelineArtifactSummarySchema.nullable()
4144
4405
  });
4145
4406
  function resolveWriteRoot(workingDir) {
4146
- return path6.join(workingDir, ".ideon", "write");
4407
+ return path7.join(workingDir, ".ideon", "write");
4147
4408
  }
4148
4409
  function resolveStateFilePath(workingDir) {
4149
- return path6.join(resolveWriteRoot(workingDir), "state.json");
4410
+ return path7.join(resolveWriteRoot(workingDir), "state.json");
4150
4411
  }
4151
4412
  async function startFreshWriteSession(seed, workingDir = process.cwd()) {
4152
4413
  const writeRoot = resolveWriteRoot(workingDir);
@@ -4196,7 +4457,7 @@ async function saveWriteSession(state, workingDir = process.cwd()) {
4196
4457
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4197
4458
  });
4198
4459
  const statePath = resolveStateFilePath(workingDir);
4199
- await mkdir4(path6.dirname(statePath), { recursive: true });
4460
+ await mkdir4(path7.dirname(statePath), { recursive: true });
4200
4461
  await writeFile5(statePath, `${JSON.stringify(next, null, 2)}
4201
4462
  `, "utf8");
4202
4463
  return next;
@@ -4289,12 +4550,15 @@ async function runPipelineShell(input, options = {}) {
4289
4550
  const stages = createInitialStages({ isArticlePrimary });
4290
4551
  options.onUpdate?.(cloneStages(stages));
4291
4552
  const dryRun = options.dryRun ?? false;
4292
- const shouldEnrichLinks = options.enrichLinks ?? true;
4553
+ const shouldEnrichLinks = options.enrichLinks ?? false;
4293
4554
  const runMode = options.runMode ?? "fresh";
4294
4555
  const workingDir = options.workingDir ?? process.cwd();
4295
4556
  const outputPaths = resolveOutputPaths(input.config.settings, workingDir);
4296
4557
  const hasArticlePrimary = isArticlePrimary;
4297
4558
  const stageTracking = /* @__PURE__ */ new Map();
4559
+ const stageRetryState = /* @__PURE__ */ new Map();
4560
+ const llmOperationRetryState = /* @__PURE__ */ new Map();
4561
+ const imageOperationRetryState = /* @__PURE__ */ new Map();
4298
4562
  stageTracking.set("shared-brief", {
4299
4563
  startedAtMs: runStartedAtMs,
4300
4564
  endedAtMs: null,
@@ -4309,6 +4573,41 @@ async function runPipelineShell(input, options = {}) {
4309
4573
  const llmInteractions = [];
4310
4574
  const t2iInteractions = [];
4311
4575
  let writeSession;
4576
+ const applyRetryUpdate = (stageId, retryIncrement, errorMessage) => {
4577
+ if (retryIncrement <= 0) {
4578
+ return;
4579
+ }
4580
+ const stageIndex = stages.findIndex((stage) => stage.id === stageId);
4581
+ if (stageIndex < 0) {
4582
+ return;
4583
+ }
4584
+ const existing = stageRetryState.get(stageId) ?? { retries: 0, lastError: null };
4585
+ const next = {
4586
+ retries: existing.retries + retryIncrement,
4587
+ lastError: errorMessage && errorMessage.trim().length > 0 ? errorMessage : existing.lastError
4588
+ };
4589
+ stageRetryState.set(stageId, next);
4590
+ stages[stageIndex] = {
4591
+ ...stages[stageIndex],
4592
+ retryCount: next.retries,
4593
+ lastRetryError: next.lastError ?? void 0
4594
+ };
4595
+ options.onUpdate?.(cloneStages(stages));
4596
+ };
4597
+ const onLlmInteraction = (interaction) => {
4598
+ llmInteractions.push(interaction);
4599
+ const stageId = asWriteStageId(interaction.stageId);
4600
+ if (!stageId) {
4601
+ return;
4602
+ }
4603
+ const previousRetries = llmOperationRetryState.get(interaction.operationId) ?? 0;
4604
+ if (interaction.retries <= previousRetries) {
4605
+ return;
4606
+ }
4607
+ const retryIncrement = interaction.retries - previousRetries;
4608
+ llmOperationRetryState.set(interaction.operationId, interaction.retries);
4609
+ applyRetryUpdate(stageId, retryIncrement, interaction.errorMessage);
4610
+ };
4312
4611
  if (runMode === "fresh") {
4313
4612
  writeSession = await startFreshWriteSession(
4314
4613
  {
@@ -4360,7 +4659,7 @@ async function runPipelineShell(input, options = {}) {
4360
4659
  openRouter,
4361
4660
  dryRun,
4362
4661
  onInteraction(interaction) {
4363
- llmInteractions.push(interaction);
4662
+ onLlmInteraction(interaction);
4364
4663
  },
4365
4664
  onLlmMetrics(metrics) {
4366
4665
  recordLlmMetrics(stageTracking, "shared-brief", metrics);
@@ -4414,7 +4713,7 @@ async function runPipelineShell(input, options = {}) {
4414
4713
  openRouter,
4415
4714
  dryRun,
4416
4715
  onInteraction(interaction) {
4417
- llmInteractions.push(interaction);
4716
+ onLlmInteraction(interaction);
4418
4717
  },
4419
4718
  onLlmMetrics(metrics) {
4420
4719
  recordLlmMetrics(stageTracking, "planning", metrics);
@@ -4477,7 +4776,7 @@ async function runPipelineShell(input, options = {}) {
4477
4776
  openRouter,
4478
4777
  dryRun,
4479
4778
  onInteraction(interaction) {
4480
- llmInteractions.push(interaction);
4779
+ onLlmInteraction(interaction);
4481
4780
  },
4482
4781
  onLlmMetrics(phase, metrics, sectionIndex) {
4483
4782
  recordLlmMetrics(stageTracking, "sections", metrics);
@@ -4590,7 +4889,7 @@ async function runPipelineShell(input, options = {}) {
4590
4889
  openRouter,
4591
4890
  dryRun,
4592
4891
  onInteraction(interaction) {
4593
- llmInteractions.push(interaction);
4892
+ onLlmInteraction(interaction);
4594
4893
  },
4595
4894
  onPromptComplete(metrics) {
4596
4895
  imagePromptCalls.push({
@@ -4676,6 +4975,7 @@ async function runPipelineShell(input, options = {}) {
4676
4975
  role: "primary",
4677
4976
  primaryContentType: primaryTarget.contentType,
4678
4977
  style: input.config.settings.style,
4978
+ intent: input.config.settings.intent,
4679
4979
  outputIndex: 1,
4680
4980
  outputCountForType: 1,
4681
4981
  articleReferenceMarkdown: void 0,
@@ -4684,7 +4984,7 @@ async function runPipelineShell(input, options = {}) {
4684
4984
  openRouter,
4685
4985
  dryRun,
4686
4986
  onInteraction(interaction) {
4687
- llmInteractions.push(interaction);
4987
+ onLlmInteraction(interaction);
4688
4988
  },
4689
4989
  onLlmMetrics(metrics) {
4690
4990
  recordLlmMetrics(stageTracking, "sections", metrics);
@@ -4728,12 +5028,12 @@ async function runPipelineShell(input, options = {}) {
4728
5028
  options.onUpdate?.(cloneStages(stages));
4729
5029
  }
4730
5030
  const baseSlug = plan?.slug ?? slugifyIdea(input.idea);
4731
- const generationDir = path7.join(
5031
+ const generationDir = path8.join(
4732
5032
  writeSession.outputPaths.markdownOutputDir,
4733
5033
  buildGenerationDirectoryName(baseSlug)
4734
5034
  );
4735
5035
  await mkdir5(generationDir, { recursive: true });
4736
- const jobDefinitionPath = path7.join(generationDir, "job.json");
5036
+ const jobDefinitionPath = path8.join(generationDir, "job.json");
4737
5037
  await writeJsonFile(
4738
5038
  jobDefinitionPath,
4739
5039
  buildRunJobDefinition({
@@ -4746,7 +5046,7 @@ async function runPipelineShell(input, options = {}) {
4746
5046
  })
4747
5047
  );
4748
5048
  const primaryFilePrefix = toFilePrefix(primaryTarget.contentType);
4749
- const primaryMarkdownPath = path7.join(generationDir, `${primaryFilePrefix}-1.md`);
5049
+ const primaryMarkdownPath = path8.join(generationDir, `${primaryFilePrefix}-1.md`);
4750
5050
  const sharedAssetDir = generationDir;
4751
5051
  if (hasArticlePrimary) {
4752
5052
  if (imageArtifacts) {
@@ -4788,6 +5088,15 @@ async function runPipelineShell(input, options = {}) {
4788
5088
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
4789
5089
  addStageRetries(stageTracking, "images", metrics.retries);
4790
5090
  },
5091
+ onRetry(event) {
5092
+ const operationKey = `images:${event.imageId}`;
5093
+ const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5094
+ if (event.retries <= previousRetries) {
5095
+ return;
5096
+ }
5097
+ imageOperationRetryState.set(operationKey, event.retries);
5098
+ applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5099
+ },
4791
5100
  onProgress(detail) {
4792
5101
  stages[4] = {
4793
5102
  ...stages[4],
@@ -4872,6 +5181,15 @@ async function runPipelineShell(input, options = {}) {
4872
5181
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
4873
5182
  addStageRetries(stageTracking, "images", metrics.retries);
4874
5183
  },
5184
+ onRetry(event) {
5185
+ const operationKey = `images:${event.imageId}`;
5186
+ const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5187
+ if (event.retries <= previousRetries) {
5188
+ return;
5189
+ }
5190
+ imageOperationRetryState.set(operationKey, event.retries);
5191
+ applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5192
+ },
4875
5193
  onProgress(detail) {
4876
5194
  stages[4] = {
4877
5195
  ...stages[4],
@@ -4969,12 +5287,13 @@ async function runPipelineShell(input, options = {}) {
4969
5287
  })
4970
5288
  };
4971
5289
  options.onUpdate?.(cloneStages(stages));
4972
- const markdownPath = path7.join(generationDir, `${output.filePrefix}-${output.index}.md`);
5290
+ const markdownPath = path8.join(generationDir, `${output.filePrefix}-${output.index}.md`);
4973
5291
  try {
4974
5292
  const content = await writeSingleShotContent({
4975
5293
  idea: input.idea,
4976
5294
  contentType: output.contentType,
4977
5295
  style: input.config.settings.style,
5296
+ intent: input.config.settings.intent,
4978
5297
  outputIndex: output.index,
4979
5298
  outputCountForType: output.outputCountForType,
4980
5299
  articleReferenceMarkdown: primaryMarkdownTemplate ?? void 0,
@@ -4985,7 +5304,7 @@ async function runPipelineShell(input, options = {}) {
4985
5304
  role: "secondary",
4986
5305
  primaryContentType: primaryTarget.contentType,
4987
5306
  onInteraction(interaction) {
4988
- llmInteractions.push(interaction);
5307
+ onLlmInteraction(interaction);
4989
5308
  },
4990
5309
  onLlmMetrics(metrics) {
4991
5310
  recordLlmMetrics(stageTracking, "output", metrics);
@@ -5030,7 +5349,7 @@ async function runPipelineShell(input, options = {}) {
5030
5349
  ...item,
5031
5350
  status: "succeeded",
5032
5351
  detail: "Saved markdown output.",
5033
- summary: path7.basename(markdownPath),
5352
+ summary: path8.basename(markdownPath),
5034
5353
  analytics: {
5035
5354
  durationMs: itemDurationMs,
5036
5355
  costUsd: knownItemCost.usd,
@@ -5086,7 +5405,7 @@ async function runPipelineShell(input, options = {}) {
5086
5405
  stages[6] = {
5087
5406
  ...stages[6],
5088
5407
  status: "succeeded",
5089
- detail: "Skipped link enrichment (--no-enrich-links).",
5408
+ detail: "Skipped link enrichment (enable with --enrich-links).",
5090
5409
  summary: "Link enrichment disabled for this run",
5091
5410
  stageAnalytics: snapshotStageAnalytics(stageTracking, "links")
5092
5411
  };
@@ -5139,7 +5458,7 @@ async function runPipelineShell(input, options = {}) {
5139
5458
  settings: input.config.settings,
5140
5459
  dryRun,
5141
5460
  onInteraction(interaction) {
5142
- llmInteractions.push(interaction);
5461
+ onLlmInteraction(interaction);
5143
5462
  },
5144
5463
  onLlmMetrics(fileId, metrics) {
5145
5464
  recordLlmMetrics(stageTracking, "links", metrics);
@@ -5238,8 +5557,8 @@ async function runPipelineShell(input, options = {}) {
5238
5557
  llmCalls: llmInteractions,
5239
5558
  t2iCalls: t2iInteractions
5240
5559
  };
5241
- const analyticsPath = path7.join(generationDir, "generation.analytics.json");
5242
- const interactionsPath = path7.join(generationDir, "model.interactions.json");
5560
+ const analyticsPath = path8.join(generationDir, "generation.analytics.json");
5561
+ const interactionsPath = path8.join(generationDir, "model.interactions.json");
5243
5562
  await writeJsonFile(analyticsPath, analytics);
5244
5563
  await writeJsonFile(interactionsPath, interactions);
5245
5564
  const primaryMarkdownPathForArtifact = markdownPaths[0] ?? primaryMarkdownPath;
@@ -5931,6 +6250,229 @@ async function runMcpServeCommand() {
5931
6250
  await startIdeonMcpServer();
5932
6251
  }
5933
6252
 
6253
+ // src/cli/commands/links.ts
6254
+ import { readFile as readFile6, stat as stat3 } from "fs/promises";
6255
+ import path9 from "path";
6256
+ async function runLinksCommand(options, dependencies = {}) {
6257
+ const slug = normalizeSlug2(options.slug);
6258
+ const mode = normalizeMode(options.mode);
6259
+ const cwd2 = dependencies.cwd ?? process.cwd();
6260
+ const log = dependencies.log ?? ((message) => console.log(message));
6261
+ const resolved = await resolveRunInput({
6262
+ idea: `Enrich links for ${slug}`
6263
+ });
6264
+ const markdownPath = await resolveMarkdownPathForSlug2(resolved.config.settings, slug, cwd2);
6265
+ const frontmatter = await readFrontmatter(markdownPath);
6266
+ const fileId = path9.parse(markdownPath).name;
6267
+ const articleTitle = frontmatter.title ?? toTitleFromSlug(slug);
6268
+ const articleDescription = frontmatter.description ?? "";
6269
+ const openRouterApiKey = resolved.config.secrets.openRouterApiKey;
6270
+ if (!openRouterApiKey) {
6271
+ throw new ReportedError(
6272
+ "Missing OpenRouter API key. Run `ideon settings` to configure credentials or set IDEON_OPENROUTER_API_KEY."
6273
+ );
6274
+ }
6275
+ const openRouter = new OpenRouterClient(openRouterApiKey);
6276
+ const linksResult = await enrichLinks({
6277
+ markdownFiles: [{ markdownPath, fileId, contentType: "article" }],
6278
+ articleTitle,
6279
+ articleDescription,
6280
+ openRouter,
6281
+ settings: resolved.config.settings,
6282
+ dryRun: false,
6283
+ onItemProgress(event) {
6284
+ logProgress(event, log);
6285
+ }
6286
+ });
6287
+ const generatedLinks = linksResult[0]?.links ?? [];
6288
+ const linksPath = resolveLinksPath(markdownPath);
6289
+ const existing = await readExistingLinks(linksPath);
6290
+ const mergedLinks = mode === "append" ? mergeLinks(existing?.links ?? [], generatedLinks) : generatedLinks;
6291
+ const appendedCount = Math.max(0, mergedLinks.length - (existing?.links.length ?? 0));
6292
+ await writeLinksFile(markdownPath, {
6293
+ version: 1,
6294
+ links: mergedLinks
6295
+ });
6296
+ const relativeMarkdownPath = formatRelativePath2(cwd2, markdownPath);
6297
+ const relativeLinksPath = formatRelativePath2(cwd2, linksPath);
6298
+ if (mode === "fresh") {
6299
+ const replaced = existing ? "Replaced existing links." : "Created links sidecar.";
6300
+ log(`Enriched links for "${slug}".`);
6301
+ log(`${replaced} Saved ${generatedLinks.length} links to ${relativeLinksPath} (${relativeMarkdownPath}).`);
6302
+ return;
6303
+ }
6304
+ const baseCount = existing?.links.length ?? 0;
6305
+ const verb = existing ? "Appended and deduplicated links." : "Created links sidecar.";
6306
+ log(`Enriched links for "${slug}".`);
6307
+ log(`${verb} Base ${baseCount}, added ${appendedCount}, total ${mergedLinks.length} in ${relativeLinksPath} (${relativeMarkdownPath}).`);
6308
+ }
6309
+ function normalizeMode(rawMode) {
6310
+ const normalized = rawMode.trim().toLowerCase();
6311
+ if (normalized === "fresh" || normalized === "append") {
6312
+ return normalized;
6313
+ }
6314
+ throw new ReportedError(`Unsupported --mode value "${rawMode}". Use "fresh" or "append".`);
6315
+ }
6316
+ function normalizeSlug2(rawSlug) {
6317
+ const slug = rawSlug.trim();
6318
+ if (!slug) {
6319
+ throw new ReportedError("Slug cannot be empty. Pass the generated article slug, for example `ideon links my-article-slug`.");
6320
+ }
6321
+ if (slug.toLowerCase().endsWith(".md")) {
6322
+ throw new ReportedError(`Expected a slug, not a markdown filename: ${slug}. Pass the slug without .md.`);
6323
+ }
6324
+ if (slug === "." || slug === ".." || /[/\\]/.test(slug)) {
6325
+ throw new ReportedError(`Invalid slug "${slug}". Pass the article slug only, without any path separators.`);
6326
+ }
6327
+ return slug;
6328
+ }
6329
+ async function resolveMarkdownPathForSlug2(settings, slug, cwd2) {
6330
+ const outputPaths = resolveOutputPaths(settings, cwd2);
6331
+ const directPath = path9.join(outputPaths.markdownOutputDir, `${slug}.md`);
6332
+ if (await isReadableFile(directPath)) {
6333
+ return directPath;
6334
+ }
6335
+ const markdownFiles = await listMarkdownFilesRecursively(outputPaths.markdownOutputDir);
6336
+ const matches = [];
6337
+ for (const candidate of markdownFiles) {
6338
+ if (path9.basename(candidate) === `${slug}.md`) {
6339
+ matches.push(candidate);
6340
+ continue;
6341
+ }
6342
+ const frontmatter = await readFrontmatter(candidate);
6343
+ if (frontmatter.slug === slug) {
6344
+ matches.push(candidate);
6345
+ }
6346
+ }
6347
+ if (matches.length === 0) {
6348
+ throw new ReportedError(
6349
+ `Could not find article "${slug}". Expected a markdown file in ${outputPaths.markdownOutputDir} with frontmatter slug "${slug}".`
6350
+ );
6351
+ }
6352
+ return newestPath(matches);
6353
+ }
6354
+ async function newestPath(paths) {
6355
+ let latestPath = paths[0];
6356
+ let latestMtime = 0;
6357
+ for (const candidate of paths) {
6358
+ const candidateStat = await stat3(candidate);
6359
+ if (candidateStat.mtimeMs >= latestMtime) {
6360
+ latestMtime = candidateStat.mtimeMs;
6361
+ latestPath = candidate;
6362
+ }
6363
+ }
6364
+ return latestPath;
6365
+ }
6366
+ async function readFrontmatter(markdownPath) {
6367
+ const markdown = await readFile6(markdownPath, "utf8");
6368
+ return parseFrontmatter(markdown);
6369
+ }
6370
+ function parseFrontmatter(markdown) {
6371
+ if (!markdown.startsWith("---\n")) {
6372
+ return { slug: null, title: null, description: null };
6373
+ }
6374
+ const frontmatterEnd = markdown.indexOf("\n---\n", 4);
6375
+ if (frontmatterEnd < 0) {
6376
+ return { slug: null, title: null, description: null };
6377
+ }
6378
+ const block = markdown.slice(4, frontmatterEnd);
6379
+ return {
6380
+ slug: parseFrontmatterValue(block, "slug"),
6381
+ title: parseFrontmatterValue(block, "title"),
6382
+ description: parseFrontmatterValue(block, "description")
6383
+ };
6384
+ }
6385
+ function parseFrontmatterValue(block, key) {
6386
+ const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
6387
+ const match = block.match(pattern);
6388
+ if (!match || !match[1]) {
6389
+ return null;
6390
+ }
6391
+ const rawValue = match[1].trim();
6392
+ if (!rawValue) {
6393
+ return null;
6394
+ }
6395
+ if (rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'")) {
6396
+ return rawValue.slice(1, -1);
6397
+ }
6398
+ return rawValue;
6399
+ }
6400
+ function toTitleFromSlug(slug) {
6401
+ return slug.split("-").filter((part) => part.length > 0).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
6402
+ }
6403
+ async function isReadableFile(filePath) {
6404
+ try {
6405
+ const fileStat = await stat3(filePath);
6406
+ return fileStat.isFile();
6407
+ } catch {
6408
+ return false;
6409
+ }
6410
+ }
6411
+ async function readExistingLinks(linksPath) {
6412
+ try {
6413
+ const raw = await readFile6(linksPath, "utf8");
6414
+ const parsed = JSON.parse(raw);
6415
+ const links = Array.isArray(parsed.links) ? parsed.links.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6416
+ expression: entry.expression.trim(),
6417
+ url: entry.url.trim(),
6418
+ title: typeof entry.title === "string" ? entry.title : null
6419
+ })) : null;
6420
+ if (!links) {
6421
+ throw new ReportedError(`Invalid links sidecar format at ${linksPath}. Expected { version, links[] }.`);
6422
+ }
6423
+ return {
6424
+ version: typeof parsed.version === "number" ? parsed.version : 1,
6425
+ links
6426
+ };
6427
+ } catch (error) {
6428
+ if (readErrorCode2(error) === "ENOENT") {
6429
+ return null;
6430
+ }
6431
+ if (error instanceof ReportedError) {
6432
+ throw error;
6433
+ }
6434
+ const message = error instanceof Error ? error.message : "Unknown links sidecar read error.";
6435
+ throw new ReportedError(`Failed to read existing links from ${linksPath}: ${message}`);
6436
+ }
6437
+ }
6438
+ function mergeLinks(existingLinks, generatedLinks) {
6439
+ const merged = [];
6440
+ const seen = /* @__PURE__ */ new Set();
6441
+ for (const entry of [...existingLinks, ...generatedLinks]) {
6442
+ const key = `${entry.expression.trim().toLowerCase()}::${entry.url.trim().toLowerCase()}`;
6443
+ if (seen.has(key)) {
6444
+ continue;
6445
+ }
6446
+ seen.add(key);
6447
+ merged.push(entry);
6448
+ }
6449
+ return merged;
6450
+ }
6451
+ function isValidLinkEntry(value2) {
6452
+ if (typeof value2 !== "object" || value2 === null) {
6453
+ return false;
6454
+ }
6455
+ const record = value2;
6456
+ return typeof record.expression === "string" && typeof record.url === "string" && (typeof record.title === "string" || record.title === null || record.title === void 0);
6457
+ }
6458
+ function readErrorCode2(error) {
6459
+ if (typeof error !== "object" || error === null || !("code" in error)) {
6460
+ return null;
6461
+ }
6462
+ const code = error.code;
6463
+ return typeof code === "string" ? code : null;
6464
+ }
6465
+ function formatRelativePath2(cwd2, targetPath) {
6466
+ const relativePath = path9.relative(cwd2, targetPath);
6467
+ return relativePath.length > 0 ? relativePath : targetPath;
6468
+ }
6469
+ function logProgress(event, log) {
6470
+ if (event.phase === "resolving-expression" || event.phase === "selecting-expressions") {
6471
+ return;
6472
+ }
6473
+ log(event.detail);
6474
+ }
6475
+
5934
6476
  // src/cli/commands/settings.tsx
5935
6477
  import { render } from "ink";
5936
6478
 
@@ -6303,12 +6845,12 @@ async function openSettings() {
6303
6845
  }
6304
6846
 
6305
6847
  // src/cli/commands/serve.ts
6306
- import path10 from "path";
6848
+ import path12 from "path";
6307
6849
  import { spawn } from "child_process";
6308
6850
 
6309
6851
  // src/server/previewHelpers.ts
6310
- import { readdir, stat as stat3, readFile as readFile6 } from "fs/promises";
6311
- import path8 from "path";
6852
+ import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
6853
+ import path10 from "path";
6312
6854
  var DEFAULT_PORT = 4173;
6313
6855
  var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter", "landing-page-copy"];
6314
6856
  var FILE_PREFIX_TO_CONTENT_TYPE = {
@@ -6368,8 +6910,8 @@ function extractHeadingTitle(markdown) {
6368
6910
  }
6369
6911
  async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
6370
6912
  if (markdownPathArg) {
6371
- const resolved = path8.isAbsolute(markdownPathArg) ? markdownPathArg : path8.resolve(cwd2, markdownPathArg);
6372
- if (path8.extname(resolved).toLowerCase() !== ".md") {
6913
+ const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
6914
+ if (path10.extname(resolved).toLowerCase() !== ".md") {
6373
6915
  throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
6374
6916
  }
6375
6917
  await assertFileExists(resolved, "Could not find markdown file");
@@ -6387,7 +6929,7 @@ async function resolveLatestMarkdown(markdownOutputDir) {
6387
6929
  let latestPath = markdownCandidates[0];
6388
6930
  let latestMtime = 0;
6389
6931
  for (const candidate of markdownCandidates) {
6390
- const fileStat = await stat3(candidate);
6932
+ const fileStat = await stat4(candidate);
6391
6933
  if (fileStat.mtimeMs >= latestMtime) {
6392
6934
  latestMtime = fileStat.mtimeMs;
6393
6935
  latestPath = candidate;
@@ -6397,7 +6939,7 @@ async function resolveLatestMarkdown(markdownOutputDir) {
6397
6939
  }
6398
6940
  async function assertFileExists(filePath, errorPrefix) {
6399
6941
  try {
6400
- const fileStat = await stat3(filePath);
6942
+ const fileStat = await stat4(filePath);
6401
6943
  if (!fileStat.isFile()) {
6402
6944
  throw new Error(`${errorPrefix}: ${filePath}`);
6403
6945
  }
@@ -6411,9 +6953,9 @@ function extractCoverImageUrl(markdown) {
6411
6953
  return match?.[1] ?? null;
6412
6954
  }
6413
6955
  async function extractArticleMetadata(markdownPath) {
6414
- const markdown = await readFile6(markdownPath, "utf8");
6415
- const fileStat = await stat3(markdownPath);
6416
- const slug = extractFrontmatterSlug(markdown) ?? path8.basename(markdownPath, ".md");
6956
+ const markdown = await readFile7(markdownPath, "utf8");
6957
+ const fileStat = await stat4(markdownPath);
6958
+ const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
6417
6959
  const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
6418
6960
  const body = stripFrontmatter2(markdown);
6419
6961
  const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
@@ -6478,16 +7020,16 @@ async function listAllGenerations(markdownOutputDir) {
6478
7020
  return generations;
6479
7021
  }
6480
7022
  function deriveGenerationId(markdownPath, markdownOutputDir) {
6481
- const relative = path8.relative(markdownOutputDir, markdownPath);
6482
- const normalized = relative.split(path8.sep).join("/");
7023
+ const relative = path10.relative(markdownOutputDir, markdownPath);
7024
+ const normalized = relative.split(path10.sep).join("/");
6483
7025
  if (!normalized || normalized.startsWith("../")) {
6484
- return path8.basename(markdownPath, ".md");
7026
+ return path10.basename(markdownPath, ".md");
6485
7027
  }
6486
7028
  const segments = normalized.split("/").filter(Boolean);
6487
7029
  if (segments.length <= 1) {
6488
- return path8.basename(markdownPath, ".md");
7030
+ return path10.basename(markdownPath, ".md");
6489
7031
  }
6490
- return segments[0] ?? path8.basename(markdownPath, ".md");
7032
+ return segments[0] ?? path10.basename(markdownPath, ".md");
6491
7033
  }
6492
7034
  async function findMarkdownFiles(markdownOutputDir) {
6493
7035
  const files = [];
@@ -6504,7 +7046,7 @@ async function findMarkdownFiles(markdownOutputDir) {
6504
7046
  continue;
6505
7047
  }
6506
7048
  for (const entry of entries) {
6507
- const fullPath = path8.join(current, entry.name);
7049
+ const fullPath = path10.join(current, entry.name);
6508
7050
  if (entry.isDirectory()) {
6509
7051
  stack.push(fullPath);
6510
7052
  continue;
@@ -6518,7 +7060,7 @@ async function findMarkdownFiles(markdownOutputDir) {
6518
7060
  }
6519
7061
  function deriveOutputIdentity(markdownPath, markdownOutputDir) {
6520
7062
  const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
6521
- const fileBase = path8.basename(markdownPath, ".md");
7063
+ const fileBase = path10.basename(markdownPath, ".md");
6522
7064
  const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
6523
7065
  if (!parsed || !parsed[1] || !parsed[2]) {
6524
7066
  return {
@@ -6554,13 +7096,13 @@ function toContentTypeLabel(contentType) {
6554
7096
  }
6555
7097
  async function resolvePrimaryContentType(outputs) {
6556
7098
  const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
6557
- const generationDir = path8.dirname(outputs[0]?.sourcePath ?? "");
7099
+ const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
6558
7100
  if (!generationDir) {
6559
7101
  return fallback;
6560
7102
  }
6561
- const jobPath = path8.join(generationDir, "job.json");
7103
+ const jobPath = path10.join(generationDir, "job.json");
6562
7104
  try {
6563
- const raw = await readFile6(jobPath, "utf8");
7105
+ const raw = await readFile7(jobPath, "utf8");
6564
7106
  const parsed = JSON.parse(raw);
6565
7107
  const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
6566
7108
  const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
@@ -6576,9 +7118,9 @@ async function resolvePrimaryContentType(outputs) {
6576
7118
  // src/server/previewServer.ts
6577
7119
  import { execFile } from "child_process";
6578
7120
  import { promisify } from "util";
6579
- import { readFile as readFile7, stat as stat4 } from "fs/promises";
7121
+ import { readFile as readFile8, stat as stat5 } from "fs/promises";
6580
7122
  import { watch as fsWatch } from "fs";
6581
- import path9 from "path";
7123
+ import path11 from "path";
6582
7124
  import { fileURLToPath } from "url";
6583
7125
  import express from "express";
6584
7126
  import { marked } from "marked";
@@ -6731,7 +7273,7 @@ async function startPreviewServer(options) {
6731
7273
  if (options.watch) {
6732
7274
  let html2;
6733
7275
  try {
6734
- html2 = await readFile7(path9.join(previewClientDir, "index.html"), "utf8");
7276
+ html2 = await readFile8(path11.join(previewClientDir, "index.html"), "utf8");
6735
7277
  } catch {
6736
7278
  res.status(200).type("html").send(
6737
7279
  `<!doctype html><html><head><meta charset="utf-8"><title>Rebuilding\u2026</title><style>body{margin:0;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;background:#101820;color:#e0eaf0}p{font-size:15px;opacity:.7}</style></head><body><p>Rebuilding\u2026</p><script>const s=new EventSource('/api/__reload');s.onmessage=function(){location.reload()};</script></body></html>`
@@ -6742,7 +7284,7 @@ async function startPreviewServer(options) {
6742
7284
  const injected = html2.replace("</body>", `${reloadScript}</body>`);
6743
7285
  res.status(200).type("html").send(injected);
6744
7286
  } else {
6745
- res.status(200).sendFile(path9.join(previewClientDir, "index.html"));
7287
+ res.status(200).sendFile(path11.join(previewClientDir, "index.html"));
6746
7288
  }
6747
7289
  return;
6748
7290
  }
@@ -6795,7 +7337,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
6795
7337
  generation.outputs.map(async (output) => {
6796
7338
  let markdown = "";
6797
7339
  try {
6798
- markdown = await readFile7(output.sourcePath, "utf8");
7340
+ markdown = await readFile8(output.sourcePath, "utf8");
6799
7341
  } catch (error) {
6800
7342
  if (isMissingFileError(error)) {
6801
7343
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
@@ -6813,7 +7355,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
6813
7355
  };
6814
7356
  })
6815
7357
  );
6816
- const generationDir = path9.dirname(generation.outputs[0]?.sourcePath ?? "");
7358
+ const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
6817
7359
  const interactions = generationDir ? await loadSavedInteractions(generationDir) : { llmCalls: [], t2iCalls: [] };
6818
7360
  const analyticsSummary = generationDir ? await loadSavedAnalyticsSummary(generationDir) : null;
6819
7361
  return {
@@ -6842,7 +7384,7 @@ async function resolveActivePreviewArticle(preferredMarkdownPath, markdownOutput
6842
7384
  };
6843
7385
  }
6844
7386
  function resolveGenerationSourcePath(generation, markdownOutputDir) {
6845
- return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path9.join(markdownOutputDir, generation.id);
7387
+ return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path11.join(markdownOutputDir, generation.id);
6846
7388
  }
6847
7389
  function isMissingFileError(error) {
6848
7390
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -6857,7 +7399,7 @@ async function renderArticleHtml(markdown, generationId, sourcePath) {
6857
7399
  async function loadSavedLinks(markdownPath) {
6858
7400
  const linksPath = resolveLinksPath(markdownPath);
6859
7401
  try {
6860
- const raw = await readFile7(linksPath, "utf8");
7402
+ const raw = await readFile8(linksPath, "utf8");
6861
7403
  const parsed = JSON.parse(raw);
6862
7404
  if (!Array.isArray(parsed.links)) {
6863
7405
  return [];
@@ -6881,9 +7423,9 @@ async function loadSavedLinks(markdownPath) {
6881
7423
  }
6882
7424
  }
6883
7425
  async function loadSavedInteractions(generationDir) {
6884
- const interactionsPath = path9.join(generationDir, "model.interactions.json");
7426
+ const interactionsPath = path11.join(generationDir, "model.interactions.json");
6885
7427
  try {
6886
- const raw = await readFile7(interactionsPath, "utf8");
7428
+ const raw = await readFile8(interactionsPath, "utf8");
6887
7429
  const parsed = JSON.parse(raw);
6888
7430
  const llmCalls = Array.isArray(parsed.llmCalls) ? parsed.llmCalls.filter(isPreviewLlmInteraction) : [];
6889
7431
  const t2iCalls = Array.isArray(parsed.t2iCalls) ? parsed.t2iCalls.filter(isPreviewT2IInteraction) : [];
@@ -6899,9 +7441,9 @@ async function loadSavedInteractions(generationDir) {
6899
7441
  }
6900
7442
  }
6901
7443
  async function loadSavedAnalyticsSummary(generationDir) {
6902
- const analyticsPath = path9.join(generationDir, "generation.analytics.json");
7444
+ const analyticsPath = path11.join(generationDir, "generation.analytics.json");
6903
7445
  try {
6904
- const raw = await readFile7(analyticsPath, "utf8");
7446
+ const raw = await readFile8(analyticsPath, "utf8");
6905
7447
  const parsed = JSON.parse(raw);
6906
7448
  const summary = parsed.summary;
6907
7449
  if (!summary || typeof summary !== "object") {
@@ -6933,14 +7475,14 @@ async function getPreviewBootstrapData(preferredMarkdownPath, markdownOutputDir)
6933
7475
  };
6934
7476
  }
6935
7477
  async function resolvePreviewClientBuildDir() {
6936
- const currentDir = path9.dirname(fileURLToPath(import.meta.url));
7478
+ const currentDir = path11.dirname(fileURLToPath(import.meta.url));
6937
7479
  const candidates = [
6938
- path9.resolve(currentDir, "preview"),
6939
- path9.resolve(currentDir, "../../dist/preview")
7480
+ path11.resolve(currentDir, "preview"),
7481
+ path11.resolve(currentDir, "../../dist/preview")
6940
7482
  ];
6941
7483
  for (const candidate of candidates) {
6942
7484
  try {
6943
- const indexStat = await stat4(path9.join(candidate, "index.html"));
7485
+ const indexStat = await stat5(path11.join(candidate, "index.html"));
6944
7486
  if (indexStat.isFile()) {
6945
7487
  return candidate;
6946
7488
  }
@@ -7002,21 +7544,21 @@ async function resolveGenerationAssetPath(generationId, rawAssetPath, markdownOu
7002
7544
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
7003
7545
  }
7004
7546
  const decodedAssetPath = decodeURIComponent(rawAssetPath);
7005
- const normalizedRelative = path9.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7006
- if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path9.posix.isAbsolute(normalizedRelative)) {
7547
+ const normalizedRelative = path11.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7548
+ if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path11.posix.isAbsolute(normalizedRelative)) {
7007
7549
  throw new Error("Invalid generation asset path.");
7008
7550
  }
7009
- const generationDir = path9.dirname(generation.outputs[0]?.sourcePath ?? "");
7551
+ const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7010
7552
  if (!generationDir) {
7011
7553
  throw new MissingArticleError(`Generation "${generationId}" has no source directory.`);
7012
7554
  }
7013
- const resolvedPath = path9.resolve(generationDir, normalizedRelative);
7014
- const relativeToGeneration = path9.relative(generationDir, resolvedPath);
7015
- if (relativeToGeneration.startsWith("..") || path9.isAbsolute(relativeToGeneration)) {
7555
+ const resolvedPath = path11.resolve(generationDir, normalizedRelative);
7556
+ const relativeToGeneration = path11.relative(generationDir, resolvedPath);
7557
+ if (relativeToGeneration.startsWith("..") || path11.isAbsolute(relativeToGeneration)) {
7016
7558
  throw new Error("Invalid generation asset path.");
7017
7559
  }
7018
7560
  try {
7019
- const fileStat = await stat4(resolvedPath);
7561
+ const fileStat = await stat5(resolvedPath);
7020
7562
  if (!fileStat.isFile()) {
7021
7563
  throw new Error("Invalid generation asset path.");
7022
7564
  }
@@ -8527,7 +9069,7 @@ async function runServeCommand(options) {
8527
9069
  const markdownPath = await resolveMarkdownPath(options.markdownPath, outputPaths.markdownOutputDir, process.cwd());
8528
9070
  const port = parsePort(options.port);
8529
9071
  if (options.watch) {
8530
- const viteBin = path10.resolve(process.cwd(), "node_modules", ".bin", "vite");
9072
+ const viteBin = path12.resolve(process.cwd(), "node_modules", ".bin", "vite");
8531
9073
  const viteProcess = spawn(viteBin, ["build", "--watch"], {
8532
9074
  stdio: "inherit",
8533
9075
  shell: process.platform === "win32"
@@ -8553,8 +9095,8 @@ async function runServeCommand(options) {
8553
9095
  openBrowser: options.openBrowser,
8554
9096
  watch: options.watch
8555
9097
  });
8556
- const relativeArticle = path10.relative(process.cwd(), markdownPath);
8557
- const relativeAssets = path10.relative(process.cwd(), outputPaths.assetOutputDir);
9098
+ const relativeArticle = path12.relative(process.cwd(), markdownPath);
9099
+ const relativeAssets = path12.relative(process.cwd(), outputPaths.assetOutputDir);
8558
9100
  console.log(`Previewing ${relativeArticle || markdownPath}`);
8559
9101
  console.log(`Serving assets from ${relativeAssets || outputPaths.assetOutputDir}`);
8560
9102
  console.log(`Open ${server.url}`);
@@ -8594,11 +9136,28 @@ function colon(id) {
8594
9136
  function value(id, text) {
8595
9137
  return { id, text, color: "white" };
8596
9138
  }
9139
+ function formatStageCost(costUsd, costSource) {
9140
+ const formatted = formatCost(costUsd);
9141
+ if (costUsd === null) {
9142
+ return formatted;
9143
+ }
9144
+ return costSource === "estimated" ? `~${formatted}` : formatted;
9145
+ }
9146
+ function formatStageId(stageId) {
9147
+ if (stageId === "shared-brief") return "shared-brief";
9148
+ if (stageId === "planning") return "planning";
9149
+ if (stageId === "sections") return "sections";
9150
+ if (stageId === "image-prompts") return "image-prompts";
9151
+ if (stageId === "images") return "images";
9152
+ if (stageId === "output") return "output";
9153
+ if (stageId === "links") return "links";
9154
+ return stageId;
9155
+ }
8597
9156
  function buildFinalSummaryRows({
8598
9157
  artifact,
8599
9158
  analytics
8600
9159
  }) {
8601
- return [
9160
+ const rows = [
8602
9161
  {
8603
9162
  id: "slug",
8604
9163
  segments: [
@@ -8648,6 +9207,16 @@ function buildFinalSummaryRows({
8648
9207
  ]
8649
9208
  }
8650
9209
  ];
9210
+ const stageCostRows = analytics.stages.map((stage) => ({
9211
+ id: `stage-cost:${stage.stageId}`,
9212
+ segments: [
9213
+ label(`stage-cost-label:${stage.stageId}`, `cost/${formatStageId(stage.stageId)}`, "greenBright"),
9214
+ colon(`stage-cost-colon:${stage.stageId}`),
9215
+ value(`stage-cost-value:${stage.stageId}`, formatStageCost(stage.costUsd, stage.costSource))
9216
+ ]
9217
+ }));
9218
+ rows.push(...stageCostRows);
9219
+ return rows;
8651
9220
  }
8652
9221
 
8653
9222
  // src/cli/ui/finalSummary.tsx
@@ -8715,7 +9284,7 @@ function formatDuration(durationMs) {
8715
9284
  }
8716
9285
  return `${durationMs}ms`;
8717
9286
  }
8718
- function formatStageCost(stage) {
9287
+ function formatStageCost2(stage) {
8719
9288
  const analytics = stage.stageAnalytics;
8720
9289
  if (!analytics || analytics.costUsd === null) {
8721
9290
  return "no cost data";
@@ -8723,6 +9292,15 @@ function formatStageCost(stage) {
8723
9292
  const formatted = `$${analytics.costUsd.toFixed(4)}`;
8724
9293
  return analytics.costSource === "estimated" ? `~${formatted}` : formatted;
8725
9294
  }
9295
+ function formatRetryContext(stage) {
9296
+ if (!stage.retryCount || stage.retryCount <= 0) {
9297
+ return "";
9298
+ }
9299
+ if (stage.lastRetryError) {
9300
+ return ` \u2022 retried ${stage.retryCount}x \u2022 last error: ${stage.lastRetryError}`;
9301
+ }
9302
+ return ` \u2022 retried ${stage.retryCount}x`;
9303
+ }
8726
9304
  function StageRow({
8727
9305
  stage,
8728
9306
  isActive,
@@ -8748,7 +9326,10 @@ function StageRow({
8748
9326
  /* @__PURE__ */ jsx4(Text3, { children: " " }),
8749
9327
  /* @__PURE__ */ jsx4(Text3, { bold: stage.status === "running", children: stage.title })
8750
9328
  ] }),
8751
- /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsx4(Text3, { color: "gray", children: stage.detail }) }),
9329
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
9330
+ stage.detail,
9331
+ formatRetryContext(stage)
9332
+ ] }) }),
8752
9333
  /* @__PURE__ */ jsx4(
8753
9334
  ItemRows,
8754
9335
  {
@@ -8763,7 +9344,7 @@ function StageRow({
8763
9344
  "analytics: ",
8764
9345
  formatDuration(stage.stageAnalytics.durationMs),
8765
9346
  " \u2022 cost: ",
8766
- formatStageCost(stage)
9347
+ formatStageCost2(stage)
8767
9348
  ] }) }) : null
8768
9349
  ] });
8769
9350
  }
@@ -8947,7 +9528,7 @@ function formatDuration2(durationMs) {
8947
9528
  }
8948
9529
  return `${durationMs}ms`;
8949
9530
  }
8950
- function formatStageCost2(stage) {
9531
+ function formatStageCost3(stage) {
8951
9532
  const analytics = stage.stageAnalytics;
8952
9533
  if (!analytics) {
8953
9534
  return "unavailable";
@@ -8959,9 +9540,19 @@ function formatStage(stage) {
8959
9540
  const summary = stage.summary ? `
8960
9541
  ${stage.summary}` : "";
8961
9542
  const analytics = stage.status === "succeeded" && stage.stageAnalytics ? `
8962
- analytics: ${formatDuration2(stage.stageAnalytics.durationMs)} \u2022 cost: ${formatStageCost2(stage)}` : "";
9543
+ analytics: ${formatDuration2(stage.stageAnalytics.durationMs)} \u2022 cost: ${formatStageCost3(stage)}` : "";
9544
+ const retryContext = formatRetryContext2(stage);
8963
9545
  return `[${stage.status}] ${stage.title}
8964
- ${stage.detail}${summary}${analytics}`;
9546
+ ${stage.detail}${retryContext}${summary}${analytics}`;
9547
+ }
9548
+ function formatRetryContext2(stage) {
9549
+ if (!stage.retryCount || stage.retryCount <= 0) {
9550
+ return "";
9551
+ }
9552
+ if (stage.lastRetryError) {
9553
+ return ` \u2022 retried ${stage.retryCount}x \u2022 last error: ${stage.lastRetryError}`;
9554
+ }
9555
+ return ` \u2022 retried ${stage.retryCount}x`;
8965
9556
  }
8966
9557
  function formatItem(stage, item) {
8967
9558
  const summary = item.summary ? `
@@ -8984,8 +9575,15 @@ function formatCost2(costUsd) {
8984
9575
  }
8985
9576
  return `$${costUsd.toFixed(4)}`;
8986
9577
  }
9578
+ function formatPipelineStageCost(stage) {
9579
+ const formatted = formatCost2(stage.costUsd);
9580
+ if (stage.costUsd === null) {
9581
+ return formatted;
9582
+ }
9583
+ return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9584
+ }
8987
9585
  async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
8988
- let previousStatuses = /* @__PURE__ */ new Map();
9586
+ let previousStages = /* @__PURE__ */ new Map();
8989
9587
  let previousItemStatuses = /* @__PURE__ */ new Map();
8990
9588
  const notificationsEnabled = input.config.settings.notifications.enabled;
8991
9589
  try {
@@ -9000,10 +9598,16 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
9000
9598
  runMode,
9001
9599
  onUpdate(stages) {
9002
9600
  for (const stage of stages) {
9003
- const previous = previousStatuses.get(stage.id);
9004
- if (previous !== stage.status) {
9601
+ const previous = previousStages.get(stage.id);
9602
+ const shouldLogStage = !previous || previous.status !== stage.status || stage.status === "running" && (previous.detail !== stage.detail || previous.retryCount !== stage.retryCount || previous.lastRetryError !== stage.lastRetryError);
9603
+ if (shouldLogStage) {
9005
9604
  console.log(formatStage(stage));
9006
- previousStatuses.set(stage.id, stage.status);
9605
+ previousStages.set(stage.id, {
9606
+ status: stage.status,
9607
+ detail: stage.detail,
9608
+ retryCount: stage.retryCount,
9609
+ lastRetryError: stage.lastRetryError
9610
+ });
9007
9611
  }
9008
9612
  for (const item of stage.items ?? []) {
9009
9613
  const itemKey = `${stage.id}:${item.id}`;
@@ -9029,6 +9633,10 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
9029
9633
  console.log(` duration_ms: ${result.analytics.summary.totalDurationMs}`);
9030
9634
  console.log(` retries: ${result.analytics.summary.totalRetries}`);
9031
9635
  console.log(` cost: ${formatCost2(result.analytics.summary.totalCostUsd)}`);
9636
+ console.log(" cost_by_stage:");
9637
+ for (const stage of result.analytics.stages) {
9638
+ console.log(` ${stage.stageId}: ${formatPipelineStageCost(stage)}`);
9639
+ }
9032
9640
  await notifyWriteSucceeded({
9033
9641
  enabled: notificationsEnabled,
9034
9642
  title: result.artifact.title,
@@ -9053,9 +9661,11 @@ import TextInput2 from "ink-text-input";
9053
9661
  import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
9054
9662
  function WriteOptionsFlow({
9055
9663
  askStyle,
9664
+ askIntent,
9056
9665
  askTargets,
9057
9666
  askLength,
9058
9667
  initialStyle,
9668
+ initialIntent,
9059
9669
  initialTargetLength,
9060
9670
  initialTargets,
9061
9671
  onDone
@@ -9064,6 +9674,7 @@ function WriteOptionsFlow({
9064
9674
  const [step, setStep] = useState3(() => {
9065
9675
  if (askTargets) return "primary";
9066
9676
  if (askStyle) return "style";
9677
+ if (askIntent) return "intent";
9067
9678
  if (askLength) return "length";
9068
9679
  return "primary";
9069
9680
  });
@@ -9089,6 +9700,7 @@ function WriteOptionsFlow({
9089
9700
  const [countInput, setCountInput] = useState3("1");
9090
9701
  const [countIndex, setCountIndex] = useState3(0);
9091
9702
  const [style, setStyle] = useState3(initialStyle);
9703
+ const [intent, setIntent] = useState3(initialIntent);
9092
9704
  const [targetLength, setTargetLength] = useState3(initialTargetLength);
9093
9705
  const selectedSecondaryTypes = useMemo2(
9094
9706
  () => secondarySelections.filter((item) => item.checked).map((item) => item.contentType),
@@ -9215,6 +9827,8 @@ function WriteOptionsFlow({
9215
9827
  if (nextIndex >= countTypes.length) {
9216
9828
  if (askStyle) {
9217
9829
  setStep("style");
9830
+ } else if (askIntent) {
9831
+ setStep("intent");
9218
9832
  } else if (askLength) {
9219
9833
  setStep("length");
9220
9834
  } else {
@@ -9252,6 +9866,10 @@ function WriteOptionsFlow({
9252
9866
  onSelect: (item) => {
9253
9867
  const contentTargets = askTargets ? buildContentTargets(primaryType, selectedSecondaryTypes) : void 0;
9254
9868
  setStyle(item.value);
9869
+ if (askIntent) {
9870
+ setStep("intent");
9871
+ return;
9872
+ }
9255
9873
  if (askLength) {
9256
9874
  setStep("length");
9257
9875
  return;
@@ -9266,6 +9884,37 @@ function WriteOptionsFlow({
9266
9884
  ) })
9267
9885
  ] });
9268
9886
  }
9887
+ const intentItems = contentIntentValues.map((value2) => ({
9888
+ label: value2,
9889
+ value: value2
9890
+ }));
9891
+ if (step === "intent") {
9892
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
9893
+ /* @__PURE__ */ jsx6(Text5, { bold: true, color: "cyanBright", children: "Select Intent" }),
9894
+ /* @__PURE__ */ jsx6(Text5, { color: "gray", children: "Choose the primary content intent for this generation run." }),
9895
+ /* @__PURE__ */ jsx6(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx6(
9896
+ SelectInput2,
9897
+ {
9898
+ items: intentItems,
9899
+ initialIndex: Math.max(0, intentItems.findIndex((item) => item.value === intent)),
9900
+ onSelect: (item) => {
9901
+ const contentTargets = askTargets ? buildContentTargets(primaryType, selectedSecondaryTypes) : void 0;
9902
+ setIntent(item.value);
9903
+ if (askLength) {
9904
+ setStep("length");
9905
+ return;
9906
+ }
9907
+ onDone({
9908
+ ...askStyle ? { style } : {},
9909
+ intent: item.value,
9910
+ ...contentTargets ? { contentTargets } : {}
9911
+ });
9912
+ exit();
9913
+ }
9914
+ }
9915
+ ) })
9916
+ ] });
9917
+ }
9269
9918
  const lengthItems = targetLengthValues.map((value2) => ({
9270
9919
  label: value2,
9271
9920
  value: value2
@@ -9284,6 +9933,7 @@ function WriteOptionsFlow({
9284
9933
  setTargetLength(item.value);
9285
9934
  onDone({
9286
9935
  ...askStyle ? { style } : {},
9936
+ ...askIntent ? { intent } : {},
9287
9937
  targetLength: item.value,
9288
9938
  ...contentTargets ? { contentTargets } : {}
9289
9939
  });
@@ -9397,7 +10047,7 @@ async function runWriteResumeCommand(options = {}) {
9397
10047
  secrets: resolved.config.secrets
9398
10048
  }
9399
10049
  };
9400
- await runWritePipeline(input, session.dryRun, true, "resume", options.noInteractive ?? false);
10050
+ await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false);
9401
10051
  }
9402
10052
  async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive) {
9403
10053
  let interruptHandled = false;
@@ -9482,6 +10132,7 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9482
10132
  audience: options.audience,
9483
10133
  jobPath: options.jobPath,
9484
10134
  style: options.style,
10135
+ intent: options.intent,
9485
10136
  targetLength: options.length,
9486
10137
  contentTargets: parsedTargets
9487
10138
  });
@@ -9499,6 +10150,7 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9499
10150
  audience: options.audience,
9500
10151
  jobPath: options.jobPath,
9501
10152
  style: options.style,
10153
+ intent: options.intent,
9502
10154
  targetLength: options.length,
9503
10155
  contentTargets: parsedTargets
9504
10156
  });
@@ -9507,12 +10159,14 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9507
10159
  }
9508
10160
  async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTargets) {
9509
10161
  const styleProvided = Boolean(options.style ?? resolved.job?.settings?.style);
10162
+ const intentProvided = Boolean(options.intent);
9510
10163
  const lengthProvided = Boolean(options.length ?? resolved.job?.settings?.targetLength);
9511
10164
  const providedTargets = parsedTargets && parsedTargets.length > 0 ? parsedTargets : resolved.job?.settings?.contentTargets ?? resolved.config.settings.contentTargets;
9512
10165
  const targetsProvided = Boolean(parsedTargets && parsedTargets.length > 0 || resolved.job?.settings?.contentTargets?.length);
9513
- if (options.noInteractive && (!styleProvided || !targetsProvided || !lengthProvided)) {
10166
+ if (options.noInteractive && (!styleProvided || !intentProvided || !targetsProvided || !lengthProvided)) {
9514
10167
  const missingFlags = [
9515
10168
  !styleProvided ? "--style <style>" : null,
10169
+ !intentProvided ? "--intent <intent>" : null,
9516
10170
  !targetsProvided ? "--primary <content-type=1>" : null,
9517
10171
  !lengthProvided ? "--length <size>" : null
9518
10172
  ].filter((value2) => Boolean(value2));
@@ -9523,15 +10177,17 @@ async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTar
9523
10177
  if (!process.stdout.isTTY || !process.stdin.isTTY || options.noInteractive) {
9524
10178
  return resolved;
9525
10179
  }
9526
- if (styleProvided && targetsProvided && lengthProvided) {
10180
+ if (styleProvided && intentProvided && targetsProvided && lengthProvided) {
9527
10181
  return resolved;
9528
10182
  }
9529
10183
  const prompted = await promptForMissingWriteOptions({
9530
10184
  askStyle: !styleProvided,
10185
+ askIntent: !intentProvided,
9531
10186
  askTargets: !targetsProvided,
9532
10187
  askLength: !lengthProvided,
9533
10188
  style: resolved.config.settings.style,
9534
10189
  targetLength: resolved.config.settings.targetLength,
10190
+ intent: resolved.config.settings.intent,
9535
10191
  targets: providedTargets
9536
10192
  });
9537
10193
  return {
@@ -9541,6 +10197,7 @@ async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTar
9541
10197
  settings: appSettingsSchema.parse({
9542
10198
  ...resolved.config.settings,
9543
10199
  ...prompted.style ? { style: prompted.style } : {},
10200
+ ...prompted.intent ? { intent: prompted.intent } : {},
9544
10201
  ...prompted.targetLength ? { targetLength: prompted.targetLength } : {},
9545
10202
  ...prompted.contentTargets ? { contentTargets: prompted.contentTargets } : {}
9546
10203
  })
@@ -9552,10 +10209,12 @@ async function promptForMissingWriteOptions(params) {
9552
10209
  const app = render2(
9553
10210
  React4.createElement(WriteOptionsFlow, {
9554
10211
  askStyle: params.askStyle,
10212
+ askIntent: params.askIntent,
9555
10213
  askTargets: params.askTargets,
9556
10214
  askLength: params.askLength,
9557
10215
  initialStyle: writingStyleValues.includes(params.style) ? params.style : "professional",
9558
- initialTargetLength: targetLengthValues.includes(params.targetLength) ? params.targetLength : "medium",
10216
+ initialIntent: contentIntentValues.includes(params.intent) ? params.intent : "tutorial",
10217
+ initialTargetLength: resolveTargetLengthAlias(params.targetLength),
9559
10218
  initialTargets: params.targets,
9560
10219
  onDone: (result) => {
9561
10220
  flowResult = result;
@@ -9639,6 +10298,12 @@ async function runCli(argv) {
9639
10298
  force: options.force
9640
10299
  });
9641
10300
  });
10301
+ program.command("links").description("Run link enrichment for a previously generated article by slug.").argument("<slug>", "Slug of the generated article to enrich").option("--mode <mode>", "Link merge mode: fresh or append", "fresh").action(async (slug, options) => {
10302
+ await runLinksCommand({
10303
+ slug,
10304
+ mode: options.mode
10305
+ });
10306
+ });
9642
10307
  program.command("preview").description("Preview a generated article in a local browser with linked assets.").argument("[markdownPath]", "Path to the markdown file to preview").option("-p, --port <port>", "Port for the local preview server (default: 4173)").option("--no-open", "Do not auto-open browser after server startup").option("--watch", "Rebuild the preview UI on source changes and auto-reload the browser", false).action(async (markdownPath, options) => {
9643
10308
  await runServeCommand({
9644
10309
  markdownPath,
@@ -9647,7 +10312,7 @@ async function runCli(argv) {
9647
10312
  watch: options.watch
9648
10313
  });
9649
10314
  });
9650
- const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (professional, friendly, technical, academic, opinionated, storytelling)").option("--length <size>", "Target length: small, medium, or large").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--no-enrich-links", "Skip link enrichment after markdown generation").action(async (ideaArg, options) => {
10315
+ const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (academic, analytical, authoritative, conversational, empathetic, friendly, journalistic, minimalist, persuasive, playful, professional, storytelling, technical)").option("--intent <intent>", "Content intent (announcement, case-study, cornerstone, counterargument, critique-review, deep-dive-analysis, how-to-guide, interview-q-and-a, listicle, opinion-piece, personal-essay, roundup-curation, tutorial)").option("--length <size>", "Target length: small, medium, large, or a positive integer word count").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--enrich-links", "Run link enrichment after markdown generation", false).action(async (ideaArg, options) => {
9651
10316
  await runWriteCommand({
9652
10317
  idea: options.idea ?? ideaArg,
9653
10318
  audience: options.audience,
@@ -9655,14 +10320,18 @@ async function runCli(argv) {
9655
10320
  primarySpec: options.primary,
9656
10321
  secondarySpecs: options.secondary,
9657
10322
  style: options.style,
10323
+ intent: options.intent,
9658
10324
  length: options.length,
9659
10325
  noInteractive: !options.interactive,
9660
10326
  dryRun: options.dryRun,
9661
10327
  enrichLinks: options.enrichLinks
9662
10328
  });
9663
10329
  });
9664
- writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).action(async (options) => {
9665
- await runWriteResumeCommand({ noInteractive: options.noInteractive });
10330
+ writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).option("--enrich-links", "Run link enrichment after markdown generation", false).action(async (options) => {
10331
+ await runWriteResumeCommand({
10332
+ noInteractive: options.noInteractive,
10333
+ enrichLinks: options.enrichLinks
10334
+ });
9666
10335
  });
9667
10336
  await program.parseAsync(argv);
9668
10337
  }