@telepat/ideon 0.1.7 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ideon.js CHANGED
@@ -12,15 +12,93 @@ 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
+ }
96
+ function resolveDefaultMaxLinks(targetLengthWords) {
97
+ const alias = resolveTargetLengthAlias(targetLengthWords);
98
+ if (alias === "small") return 5;
99
+ if (alias === "medium") return 8;
100
+ return 12;
101
+ }
24
102
  var contentTargetRoleValues = ["primary", "secondary"];
25
103
  var contentTargetSchema = z.object({
26
104
  contentType: z.enum(contentTypeValues),
@@ -51,7 +129,8 @@ var appSettingsSchema = z.object({
51
129
  message: "contentTargets must include exactly one primary target."
52
130
  }).default([{ contentType: "article", role: "primary", count: 1 }]),
53
131
  style: z.enum(writingStyleValues).default("professional"),
54
- targetLength: z.enum(targetLengthValues).default("medium")
132
+ intent: z.enum(contentIntentValues).default("tutorial"),
133
+ targetLength: targetLengthWordsSchema.default(defaultTargetLengthWords)
55
134
  });
56
135
  var envSettingsSchema = z.object({
57
136
  openRouterApiKey: z.string().optional(),
@@ -66,7 +145,8 @@ var envSettingsSchema = z.object({
66
145
  markdownOutputDir: z.string().optional(),
67
146
  assetOutputDir: z.string().optional(),
68
147
  style: z.enum(writingStyleValues).optional(),
69
- targetLength: z.enum(targetLengthValues).optional()
148
+ intent: z.enum(contentIntentValues).optional(),
149
+ targetLength: targetLengthWordsSchema.optional()
70
150
  });
71
151
  var jobInputSchema = z.object({
72
152
  idea: z.string().min(1).optional(),
@@ -111,6 +191,7 @@ function readEnvSettings(env = process.env) {
111
191
  markdownOutputDir: env.IDEON_MARKDOWN_OUTPUT_DIR,
112
192
  assetOutputDir: env.IDEON_ASSET_OUTPUT_DIR,
113
193
  style: env.IDEON_STYLE,
194
+ intent: env.IDEON_INTENT,
114
195
  targetLength: env.IDEON_TARGET_LENGTH
115
196
  });
116
197
  }
@@ -179,6 +260,37 @@ function buildGenerationDirectoryName(baseSlug, now = /* @__PURE__ */ new Date()
179
260
  ].join("");
180
261
  return `${stamp}-${baseSlug}`;
181
262
  }
263
+ async function listMarkdownFilesRecursively(rootDir) {
264
+ return listFilesRecursively(rootDir, (fileName) => fileName.toLowerCase().endsWith(".md"));
265
+ }
266
+ async function listFilesRecursively(rootDir, predicate) {
267
+ const fs = await import("fs/promises");
268
+ const results = [];
269
+ const stack = [rootDir];
270
+ while (stack.length > 0) {
271
+ const current = stack.pop();
272
+ if (!current) {
273
+ continue;
274
+ }
275
+ let entries;
276
+ try {
277
+ entries = await fs.readdir(current, { withFileTypes: true });
278
+ } catch {
279
+ continue;
280
+ }
281
+ for (const entry of entries) {
282
+ const fullPath = path2.join(current, entry.name);
283
+ if (entry.isDirectory()) {
284
+ stack.push(fullPath);
285
+ continue;
286
+ }
287
+ if (entry.isFile() && predicate(entry.name)) {
288
+ results.push(fullPath);
289
+ }
290
+ }
291
+ }
292
+ return results;
293
+ }
182
294
  async function writeUtf8File(filePath, content) {
183
295
  await mkdir2(path2.dirname(filePath), { recursive: true });
184
296
  await writeFile2(filePath, content, "utf8");
@@ -637,6 +749,7 @@ var configSettingKeys = [
637
749
  "markdownOutputDir",
638
750
  "assetOutputDir",
639
751
  "style",
752
+ "intent",
640
753
  "targetLength"
641
754
  ];
642
755
  var configSecretKeys = ["openRouterApiKey", "replicateApiToken"];
@@ -665,6 +778,7 @@ async function configList() {
665
778
  markdownOutputDir: settings.markdownOutputDir,
666
779
  assetOutputDir: settings.assetOutputDir,
667
780
  style: settings.style,
781
+ intent: settings.intent,
668
782
  targetLength: settings.targetLength
669
783
  },
670
784
  secrets: {
@@ -767,12 +881,23 @@ function coerceSettingValue(key, rawValue) {
767
881
  }
768
882
  return trimmed;
769
883
  }
770
- case "targetLength": {
771
- if (!targetLengthValues.includes(trimmed)) {
772
- throw new Error(`targetLength must be one of: ${targetLengthValues.join(", ")}.`);
884
+ case "intent": {
885
+ if (!contentIntentValues.includes(trimmed)) {
886
+ throw new Error(`intent must be one of: ${contentIntentValues.join(", ")}.`);
773
887
  }
774
888
  return trimmed;
775
889
  }
890
+ case "targetLength": {
891
+ const normalized = trimmed.toLowerCase();
892
+ if (targetLengthValues.includes(normalized)) {
893
+ return normalized;
894
+ }
895
+ const parsed = Number.parseInt(trimmed, 10);
896
+ if (!Number.isFinite(parsed) || parsed <= 0) {
897
+ throw new Error(`targetLength must be one of: ${targetLengthValues.join(", ")}, or a positive integer word count.`);
898
+ }
899
+ return parsed;
900
+ }
776
901
  default:
777
902
  throw new Error(`Unsupported config key: ${key}`);
778
903
  }
@@ -797,6 +922,8 @@ function getSettingValue(settings, key) {
797
922
  return settings.assetOutputDir;
798
923
  case "style":
799
924
  return settings.style;
925
+ case "intent":
926
+ return settings.intent;
800
927
  case "targetLength":
801
928
  return settings.targetLength;
802
929
  default:
@@ -823,6 +950,8 @@ function setSettingValue(settings, key, value2) {
823
950
  return { ...settings, assetOutputDir: value2 };
824
951
  case "style":
825
952
  return { ...settings, style: value2 };
953
+ case "intent":
954
+ return { ...settings, intent: value2 };
826
955
  case "targetLength":
827
956
  return { ...settings, targetLength: value2 };
828
957
  default:
@@ -840,7 +969,8 @@ var writeToolInputSchema = {
840
969
  primary: z3.string().optional(),
841
970
  secondary: z3.array(z3.string()).optional(),
842
971
  style: z3.enum(writingStyleValues).optional(),
843
- length: z3.enum(targetLengthValues).optional(),
972
+ intent: z3.enum(contentIntentValues).optional(),
973
+ length: z3.union([z3.enum(targetLengthValues), z3.coerce.number().int().positive()]).optional(),
844
974
  dryRun: z3.boolean().optional(),
845
975
  enrichLinks: z3.boolean().optional()
846
976
  };
@@ -869,6 +999,7 @@ var ideonToolContracts = [
869
999
  required: ["idea"],
870
1000
  enums: {
871
1001
  style: [...writingStyleValues],
1002
+ intent: [...contentIntentValues],
872
1003
  length: [...targetLengthValues]
873
1004
  }
874
1005
  },
@@ -899,6 +1030,7 @@ var ideonSkillRegistry = [
899
1030
  required: ["idea"],
900
1031
  enums: {
901
1032
  style: [...writingStyleValues],
1033
+ intent: [...contentIntentValues],
902
1034
  length: [...targetLengthValues]
903
1035
  }
904
1036
  }
@@ -959,6 +1091,18 @@ function validateIntegrationContracts(sources = {
959
1091
  [...writeSkill.inputContract.enums.style ?? []].sort(),
960
1092
  [...writingStyleValues].sort()
961
1093
  );
1094
+ compareStringArrays(
1095
+ drifts,
1096
+ "write.enum.intent.tool-vs-schema",
1097
+ [...writeTool.enums.intent ?? []].sort(),
1098
+ [...contentIntentValues].sort()
1099
+ );
1100
+ compareStringArrays(
1101
+ drifts,
1102
+ "write.enum.intent.skill-vs-schema",
1103
+ [...writeSkill.inputContract.enums.intent ?? []].sort(),
1104
+ [...contentIntentValues].sort()
1105
+ );
962
1106
  compareStringArrays(
963
1107
  drifts,
964
1108
  "write.enum.length.tool-vs-schema",
@@ -1161,7 +1305,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1161
1305
  // package.json
1162
1306
  var package_default = {
1163
1307
  name: "@telepat/ideon",
1164
- version: "0.1.7",
1308
+ version: "0.1.14",
1165
1309
  description: "CLI for generating rich articles and images from ideas.",
1166
1310
  type: "module",
1167
1311
  repository: {
@@ -1301,8 +1445,10 @@ async function resolveRunInput(input) {
1301
1445
  ...envSettings.markdownOutputDir ? { markdownOutputDir: envSettings.markdownOutputDir } : {},
1302
1446
  ...envSettings.assetOutputDir ? { assetOutputDir: envSettings.assetOutputDir } : {},
1303
1447
  ...envSettings.style ? { style: envSettings.style } : {},
1448
+ ...envSettings.intent ? { intent: envSettings.intent } : {},
1304
1449
  ...envSettings.targetLength ? { targetLength: envSettings.targetLength } : {},
1305
1450
  ...input.style ? { style: input.style } : {},
1451
+ ...input.intent ? { intent: input.intent } : {},
1306
1452
  ...input.targetLength ? { targetLength: input.targetLength } : {},
1307
1453
  ...input.contentTargets ? { contentTargets: input.contentTargets } : {}
1308
1454
  });
@@ -1362,13 +1508,13 @@ function assertNoLegacyXMode(contentTargets, sourceLabel) {
1362
1508
  // src/pipeline/runner.ts
1363
1509
  import { mkdir as mkdir5, stat as stat2 } from "fs/promises";
1364
1510
  import { randomUUID } from "crypto";
1365
- import path7 from "path";
1511
+ import path8 from "path";
1366
1512
 
1367
1513
  // src/generation/enrichLinks.ts
1368
1514
  import { readFile as readFile4 } from "fs/promises";
1369
1515
 
1370
1516
  // src/llm/prompts/linkEnrichment.ts
1371
- function buildLinkCandidatesJsonSchema() {
1517
+ function buildLinkCandidatesJsonSchema(maxLinks = 10) {
1372
1518
  return {
1373
1519
  type: "object",
1374
1520
  additionalProperties: false,
@@ -1377,13 +1523,13 @@ function buildLinkCandidatesJsonSchema() {
1377
1523
  expressions: {
1378
1524
  type: "array",
1379
1525
  minItems: 0,
1380
- maxItems: 10,
1526
+ maxItems: maxLinks,
1381
1527
  items: { type: "string", minLength: 2 }
1382
1528
  }
1383
1529
  }
1384
1530
  };
1385
1531
  }
1386
- function buildLinkCandidatesMessages(content, contentType) {
1532
+ function buildLinkCandidatesMessages(content, contentType, maxLinks = 10) {
1387
1533
  return [
1388
1534
  {
1389
1535
  role: "system",
@@ -1399,7 +1545,7 @@ function buildLinkCandidatesMessages(content, contentType) {
1399
1545
  role: "user",
1400
1546
  content: [
1401
1547
  `Content type: ${contentType}`,
1402
- "Select up to 10 expressions that should become links in this content.",
1548
+ `Select up to ${maxLinks} expressions that should become links in this content.`,
1403
1549
  "Each expression must be copied exactly from the text and be useful to link.",
1404
1550
  "",
1405
1551
  "Content:",
@@ -1454,6 +1600,8 @@ async function enrichLinks({
1454
1600
  openRouter,
1455
1601
  settings,
1456
1602
  dryRun,
1603
+ customLinks = [],
1604
+ maxLinks = 10,
1457
1605
  onLlmMetrics,
1458
1606
  onItemProgress,
1459
1607
  onInteraction
@@ -1475,7 +1623,8 @@ async function enrichLinks({
1475
1623
  fileId: item.fileId,
1476
1624
  contentType: item.contentType,
1477
1625
  markdownPath: item.markdownPath,
1478
- links: []
1626
+ links: [],
1627
+ customLinks
1479
1628
  });
1480
1629
  continue;
1481
1630
  }
@@ -1494,7 +1643,8 @@ async function enrichLinks({
1494
1643
  fileId: item.fileId,
1495
1644
  contentType: item.contentType,
1496
1645
  markdownPath: item.markdownPath,
1497
- links: []
1646
+ links: [],
1647
+ customLinks
1498
1648
  });
1499
1649
  continue;
1500
1650
  }
@@ -1505,8 +1655,8 @@ async function enrichLinks({
1505
1655
  });
1506
1656
  const candidateResult = await openRouter.requestStructured({
1507
1657
  schemaName: "link_candidates",
1508
- schema: buildLinkCandidatesJsonSchema(),
1509
- messages: buildLinkCandidatesMessages(content, item.contentType),
1658
+ schema: buildLinkCandidatesJsonSchema(maxLinks),
1659
+ messages: buildLinkCandidatesMessages(content, item.contentType, maxLinks),
1510
1660
  settings,
1511
1661
  reasoning: LINKS_REASONING_SETTINGS,
1512
1662
  interactionContext: {
@@ -1517,7 +1667,10 @@ async function enrichLinks({
1517
1667
  parse(data) {
1518
1668
  const record = data;
1519
1669
  const expressions = Array.isArray(record.expressions) ? record.expressions.filter((value2) => typeof value2 === "string") : [];
1520
- return { expressions: dedupeExpressions(expressions).slice(0, 10) };
1670
+ const customExpressions = new Set(customLinks.map((e) => e.expression.trim().toLowerCase()));
1671
+ return {
1672
+ expressions: dedupeExpressions(expressions).filter((expr) => !customExpressions.has(expr.trim().toLowerCase())).slice(0, maxLinks)
1673
+ };
1521
1674
  },
1522
1675
  onMetrics(metrics) {
1523
1676
  onLlmMetrics?.(item.fileId, metrics);
@@ -1594,7 +1747,8 @@ async function enrichLinks({
1594
1747
  fileId: item.fileId,
1595
1748
  contentType: item.contentType,
1596
1749
  markdownPath: item.markdownPath,
1597
- links
1750
+ links,
1751
+ customLinks
1598
1752
  });
1599
1753
  }
1600
1754
  return results;
@@ -1669,44 +1823,6 @@ function toExpressionPreview(expression, maxLength = 60) {
1669
1823
  }
1670
1824
 
1671
1825
  // 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
1826
  function buildRunContextDirective(contentTypes) {
1711
1827
  const normalizedTypes = contentTypes.length > 0 ? contentTypes.join(", ") : "article";
1712
1828
  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 +1832,148 @@ var TARGET_LENGTH_TIERS = {
1716
1832
  label: "small",
1717
1833
  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
1834
  "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
1835
  "linkedin-post": "Target length (small linkedin post): 50\u2013150 words, 3\u20136 lines, single insight.",
1723
1836
  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.",
1837
+ "press-release": "Target length (small press release): 300\u2013700 words with headline, lead, core announcement details, and concise quote block.",
1838
+ "reddit-post": "Target length (small reddit post): 150\u2013400 words. Quick question or observation, minimal formatting.",
1839
+ "science-paper": "Target length (small science paper): 800\u20131,400 words condensed structure with abstract-style opener, methods summary, and key findings.",
1840
+ "x-post": "Target length (small x-post): 70\u2013150 characters, one idea, pure hook or insight.",
1841
+ "x-thread": "Target length (small x-thread): 3\u20135 posts, each post one clear idea with momentum from one step to the next.",
1725
1842
  fallback: "Target length (small): 50\u2013300 words. Compressed insight density. Prioritise hooks and key points over elaboration."
1726
1843
  },
1727
1844
  medium: {
1728
1845
  label: "medium",
1729
1846
  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
1847
  "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
1848
  "linkedin-post": "Target length (medium linkedin post): 150\u2013400 words, 8\u201315 short lines, story plus takeaway. Best performing range.",
1735
1849
  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.",
1850
+ "press-release": "Target length (medium press release): 700\u20131,200 words with complete release anatomy, context, quote, and next-step details.",
1851
+ "reddit-post": "Target length (medium reddit post): 400\u20131,200 words. Context, experience, and question. Conversational tone.",
1852
+ "science-paper": "Target length (medium science paper): 1,400\u20132,600 words with clearer methodological depth, results framing, and discussion of limitations.",
1853
+ "x-post": "Target length (medium x-post): 150\u2013280 characters or 2\u20134 short lines, 1\u20132 ideas with slight expansion.",
1854
+ "x-thread": "Target length (medium x-thread): 5\u20138 posts, each post concise and additive, with clear narrative progression.",
1737
1855
  fallback: "Target length (medium): 300\u20131,200 words. Balanced depth and breadth with examples and actionable takeaways."
1738
1856
  },
1739
1857
  large: {
1740
1858
  label: "large",
1741
1859
  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
1860
  "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
1861
  "linkedin-post": "Target length (large linkedin post): 400\u2013900 words. Structured storytelling, multiple insights. Use sparingly for deep authority posts.",
1747
1862
  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.",
1863
+ "press-release": "Target length (large press release): 1,200\u20132,000+ words with full context, expanded quote material, and detailed release implications.",
1864
+ "reddit-post": "Target length (large reddit post): 1,200\u20132,500+ words. Detailed breakdown with story, lessons, numbers, and mistakes.",
1865
+ "science-paper": "Target length (large science paper): 2,600\u20134,500+ words with full narrative arc from research question through methods, results, and implications.",
1866
+ "x-post": "Target length (large x-post): 200\u2013300 characters, one strong stance plus one supporting detail or proof.",
1867
+ "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
1868
  fallback: "Target length (large): 1,200\u20133,500+ words. Deep exploration with frameworks, multiple examples, and expanded narrative."
1750
1869
  }
1751
1870
  };
1752
- function buildTargetLengthDirective(contentType, targetLength) {
1753
- const tier = TARGET_LENGTH_TIERS[targetLength] ?? TARGET_LENGTH_TIERS["medium"];
1754
- return tier[contentType] ?? tier.fallback;
1871
+ function buildTargetLengthDirective(contentType, targetLengthWords) {
1872
+ const normalizedTargetLengthWords = Number.isFinite(targetLengthWords) && targetLengthWords > 0 ? Math.round(targetLengthWords) : 900;
1873
+ const alias = resolveTargetLengthAlias(normalizedTargetLengthWords);
1874
+ const tier = TARGET_LENGTH_TIERS[alias] ?? TARGET_LENGTH_TIERS["medium"];
1875
+ if (contentType === "article") {
1876
+ return `Target length (article): aim for about ${normalizedTargetLengthWords} words total while keeping section depth and structure consistent.`;
1877
+ }
1878
+ const baseDirective = tier[contentType] ?? tier.fallback;
1879
+ return `${baseDirective} Overall run target is about ${normalizedTargetLengthWords} words.`;
1880
+ }
1881
+
1882
+ // src/llm/prompts/guideBundles.ts
1883
+ import { existsSync, readFileSync } from "fs";
1884
+ import path5 from "path";
1885
+ var guideCache = /* @__PURE__ */ new Map();
1886
+ function normalizeGuideContent(content) {
1887
+ return content.replace(/\r\n/g, "\n").trim();
1888
+ }
1889
+ function readGuideFile(relativePath) {
1890
+ const cached = guideCache.get(relativePath);
1891
+ if (cached) {
1892
+ return cached;
1893
+ }
1894
+ const absolutePath = path5.resolve(process.cwd(), relativePath);
1895
+ if (!existsSync(absolutePath)) {
1896
+ const fallback = `Guide unavailable: ${relativePath}. Continue with the remaining guidance.`;
1897
+ guideCache.set(relativePath, fallback);
1898
+ return fallback;
1899
+ }
1900
+ try {
1901
+ const content = normalizeGuideContent(readFileSync(absolutePath, "utf8"));
1902
+ guideCache.set(relativePath, content);
1903
+ return content;
1904
+ } catch {
1905
+ const fallback = `Guide failed to load: ${relativePath}. Continue with the remaining guidance.`;
1906
+ guideCache.set(relativePath, fallback);
1907
+ return fallback;
1908
+ }
1909
+ }
1910
+ function buildGuideSection(relativePath) {
1911
+ const content = readGuideFile(relativePath);
1912
+ return [
1913
+ `Guide source: ${relativePath}`,
1914
+ content
1915
+ ].join("\n");
1916
+ }
1917
+ function formatToGuidePath(contentType) {
1918
+ return `writing-guide/formats/${contentType}.md`;
1919
+ }
1920
+ function intentToGuidePath(intent) {
1921
+ return `writing-guide/content-intent/${intent}.md`;
1922
+ }
1923
+ function styleToGuidePath(style) {
1924
+ return `writing-guide/styles/${style}.md`;
1925
+ }
1926
+ function dedupe(items) {
1927
+ return Array.from(new Set(items));
1928
+ }
1929
+ function buildGuideBundle(relativePaths) {
1930
+ const blocks = dedupe(relativePaths).map((relativePath) => buildGuideSection(relativePath));
1931
+ return [
1932
+ "External writing guides (apply these rules directly):",
1933
+ ...blocks
1934
+ ].join("\n\n");
1935
+ }
1936
+ function buildArticlePlanGuideInstruction(intent, contentType) {
1937
+ return buildGuideBundle([
1938
+ "writing-guide/references/headline-writing-systems.md",
1939
+ "writing-guide/references/ideation-and-credibility-systems.md",
1940
+ "writing-guide/references/content-frameworks.md",
1941
+ intentToGuidePath(intent),
1942
+ formatToGuidePath(contentType)
1943
+ ]);
1944
+ }
1945
+ function buildArticleSectionGuideInstruction(style, intent, contentType) {
1946
+ return buildGuideBundle([
1947
+ "writing-guide/general/core-web-writing-rules.md",
1948
+ "writing-guide/references/emotional-resonance.md",
1949
+ "writing-guide/references/prose-quality-checks.md",
1950
+ "writing-guide/references/readability-and-pace.md",
1951
+ "writing-guide/references/skimmability-patterns.md",
1952
+ styleToGuidePath(style),
1953
+ intentToGuidePath(intent),
1954
+ formatToGuidePath(contentType)
1955
+ ]);
1956
+ }
1957
+ function buildContentBriefGuideInstruction(intent, primaryContentType, secondaryContentTypes) {
1958
+ return buildGuideBundle([
1959
+ "writing-guide/references/multi-channel-brief-strategy.md",
1960
+ "writing-guide/references/content-frameworks.md",
1961
+ "writing-guide/references/target-length-guidance.md",
1962
+ intentToGuidePath(intent),
1963
+ formatToGuidePath(primaryContentType),
1964
+ ...secondaryContentTypes.map((contentType) => formatToGuidePath(contentType))
1965
+ ]);
1966
+ }
1967
+ function buildChannelContentGuideInstruction(style, intent, contentType) {
1968
+ const conditionalGuides = contentType === "x-thread" ? ["writing-guide/references/x-thread-hooks.md"] : [];
1969
+ return buildGuideBundle([
1970
+ "writing-guide/references/truthful-value-framing.md",
1971
+ "writing-guide/references/target-length-guidance.md",
1972
+ ...conditionalGuides,
1973
+ styleToGuidePath(style),
1974
+ intentToGuidePath(intent),
1975
+ formatToGuidePath(contentType)
1976
+ ]);
1755
1977
  }
1756
1978
 
1757
1979
  // src/llm/prompts/contentBrief.ts
@@ -1791,11 +2013,11 @@ var contentBriefSchema = {
1791
2013
  };
1792
2014
  function buildContentBriefMessages(idea, options) {
1793
2015
  const audienceSeed = options.targetAudienceHint?.trim() || "A general, non-specific audience.";
2016
+ const hasSecondaryContentTypes = options.secondaryContentTypes.length > 0;
1794
2017
  const systemInstruction = [
1795
2018
  "You are a senior editorial strategist.",
1796
2019
  "Produce a shared content brief that can guide all requested content types in this run.",
1797
- buildWritingFrameworkInstruction(),
1798
- buildStyleDirective(options.style),
2020
+ buildContentBriefGuideInstruction(options.intent, options.primaryContentType, options.secondaryContentTypes),
1799
2021
  buildRunContextDirective([options.primaryContentType, ...options.secondaryContentTypes]),
1800
2022
  "The brief must be specific, concrete, and directly usable by writers without extra clarification.",
1801
2023
  "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 +2046,7 @@ function buildContentBriefMessages(idea, options) {
1824
2046
  "- voiceNotes: practical tone/voice constraints to keep outputs consistent.",
1825
2047
  `- primaryContentType: set to "${options.primaryContentType}" exactly.`,
1826
2048
  `- 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.",
2049
+ 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
2050
  "",
1829
2051
  "Return JSON only with all required fields."
1830
2052
  ].join("\n")
@@ -1834,6 +2056,15 @@ function buildContentBriefMessages(idea, options) {
1834
2056
 
1835
2057
  // src/types/contentBriefSchema.ts
1836
2058
  import { z as z4 } from "zod";
2059
+ var secondaryTypeSentinelValues = /* @__PURE__ */ new Set([
2060
+ "none",
2061
+ "n/a",
2062
+ "na",
2063
+ "null",
2064
+ "not applicable",
2065
+ "no secondary content",
2066
+ "no secondary outputs"
2067
+ ]);
1837
2068
  var contentBriefSchema2 = z4.object({
1838
2069
  title: z4.string().min(8),
1839
2070
  description: z4.string().min(40),
@@ -1842,8 +2073,24 @@ var contentBriefSchema2 = z4.object({
1842
2073
  keyPoints: z4.array(z4.string().min(8)).min(3).max(6),
1843
2074
  voiceNotes: z4.string().min(20),
1844
2075
  primaryContentType: z4.string().min(2),
1845
- secondaryContentTypes: z4.array(z4.string().min(2)).max(10),
1846
- secondaryContentStrategy: z4.string().min(20)
2076
+ 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()))),
2077
+ secondaryContentStrategy: z4.string()
2078
+ }).superRefine((brief, ctx) => {
2079
+ const hasSecondaryTargets = brief.secondaryContentTypes.length > 0;
2080
+ if (!hasSecondaryTargets) {
2081
+ return;
2082
+ }
2083
+ if (brief.secondaryContentStrategy.trim().length < 20) {
2084
+ ctx.addIssue({
2085
+ code: z4.ZodIssueCode.too_small,
2086
+ minimum: 20,
2087
+ inclusive: true,
2088
+ origin: "string",
2089
+ path: ["secondaryContentStrategy"],
2090
+ type: "string",
2091
+ message: "Too small: expected string to have >=20 characters"
2092
+ });
2093
+ }
1847
2094
  });
1848
2095
 
1849
2096
  // src/generation/planContentBrief.ts
@@ -1871,7 +2118,7 @@ async function planContentBrief({
1871
2118
  schemaName: "content_brief",
1872
2119
  schema: contentBriefSchema,
1873
2120
  messages: buildContentBriefMessages(idea, {
1874
- style: settings.style,
2121
+ intent: settings.intent,
1875
2122
  targetAudienceHint,
1876
2123
  primaryContentType: settings.contentTargets.find((target) => target.role === "primary")?.contentType ?? "article",
1877
2124
  secondaryContentTypes: settings.contentTargets.filter((target) => target.role === "secondary").map((target) => target.contentType)
@@ -1916,13 +2163,19 @@ function deriveTitleFromIdea(idea) {
1916
2163
  }
1917
2164
 
1918
2165
  // 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"];
2166
+ function deriveArticleSectionCounts(targetLengthWords) {
2167
+ const normalizedWords = Number.isFinite(targetLengthWords) && targetLengthWords > 0 ? targetLengthWords : 900;
2168
+ const center = Math.max(2, Math.min(10, Math.round(normalizedWords / 220)));
2169
+ const min = Math.max(2, center - 1);
2170
+ const max = Math.min(10, center + 1);
2171
+ return {
2172
+ min,
2173
+ max,
2174
+ label: `${min} to ${max}`
2175
+ };
2176
+ }
2177
+ function buildArticlePlanJsonSchema(targetLengthWords) {
2178
+ const sectionCounts = deriveArticleSectionCounts(targetLengthWords);
1926
2179
  return {
1927
2180
  type: "object",
1928
2181
  additionalProperties: false,
@@ -1984,16 +2237,12 @@ function buildArticlePlanJsonSchema(targetLength) {
1984
2237
  };
1985
2238
  }
1986
2239
  function buildArticlePlanMessages(idea, options) {
1987
- const sectionCounts = ARTICLE_SECTION_COUNTS[options.targetLength] ?? ARTICLE_SECTION_COUNTS["medium"];
2240
+ const sectionCounts = deriveArticleSectionCounts(options.targetLength);
1988
2241
  const systemInstruction = [
1989
2242
  "You are a senior editorial strategist. Produce a rigorous article plan for a polished long-form Markdown article.",
1990
- buildWritingFrameworkInstruction(),
1991
- buildStyleDirective(options.style),
2243
+ buildArticlePlanGuideInstruction(options.intent, "article"),
1992
2244
  buildRunContextDirective(options.contentTypes),
1993
2245
  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
2246
  "Return only the requested JSON."
1998
2247
  ].join(" ");
1999
2248
  return [
@@ -2011,7 +2260,7 @@ function buildArticlePlanMessages(idea, options) {
2011
2260
  "- The article should feel authoritative, practical, and clearly structured for scanning and deep reading.",
2012
2261
  "- Generate a memorable title and a sharp subtitle that promise a concrete benefit, mechanism, or outcome.",
2013
2262
  "- 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.",
2263
+ "- The description should work as a concise meta description and align with the shared content brief.",
2015
2264
  `- Plan ${sectionCounts.label} strong sections with distinct focus areas and logical progression (no repetitive section intent).`,
2016
2265
  "- Frame section titles to reflect likely search intent or practical reader questions when appropriate.",
2017
2266
  "- Each section description should name the mechanism, evidence type, or practical action that makes the section useful.",
@@ -2019,7 +2268,6 @@ function buildArticlePlanMessages(idea, options) {
2019
2268
  "- Include a cover image description and 2 to 3 inline image descriptions.",
2020
2269
  "- Image descriptions must be concrete and contextual, not generic stock-photo phrasing.",
2021
2270
  "- 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
2271
  "",
2024
2272
  "Shared content brief context:",
2025
2273
  `- description: ${options.contentBrief.description}`,
@@ -2087,7 +2335,7 @@ async function planArticle({
2087
2335
  schemaName: "article_plan",
2088
2336
  schema: buildArticlePlanJsonSchema(settings.targetLength),
2089
2337
  messages: buildArticlePlanMessages(idea, {
2090
- style: settings.style,
2338
+ intent: settings.intent,
2091
2339
  contentTypes: settings.contentTargets.map((target) => target.contentType),
2092
2340
  contentBrief,
2093
2341
  targetLength: settings.targetLength
@@ -2162,40 +2410,17 @@ function slugify(value2) {
2162
2410
  }
2163
2411
 
2164
2412
  // 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
- };
2413
+ function buildOutputShapeConstraint(contentType) {
2414
+ if (contentType === "x-thread") {
2415
+ return 'Return a numbered thread with one post per line prefixed like "1/7".';
2416
+ }
2417
+ if (contentType === "x-post") {
2418
+ return "Return one concise post only. Do not return numbered thread lines.";
2419
+ }
2420
+ return "";
2421
+ }
2197
2422
  function buildSingleShotContentMessages(options) {
2198
- const channelRule = CHANNEL_RULES[options.contentType] ?? "Write channel-native Markdown content.";
2423
+ const outputShapeConstraint = buildOutputShapeConstraint(options.contentType);
2199
2424
  const articleContext = options.articleReferenceMarkdown ? [
2200
2425
  "Reference primary context (use as anchor source, but adapt natively for the requested channel):",
2201
2426
  options.articleReferenceMarkdown
@@ -2213,10 +2438,9 @@ function buildSingleShotContentMessages(options) {
2213
2438
  content: [
2214
2439
  "You are a senior content strategist and copywriter.",
2215
2440
  `Write exactly one ${options.contentType} output.`,
2216
- buildWritingFrameworkInstruction(),
2217
- buildStyleDirective(options.style),
2441
+ buildChannelContentGuideInstruction(options.style, options.intent, options.contentType),
2218
2442
  roleDirective,
2219
- channelRule
2443
+ outputShapeConstraint
2220
2444
  ].join(" ")
2221
2445
  },
2222
2446
  {
@@ -2257,6 +2481,7 @@ async function writeSingleShotContent({
2257
2481
  role = "secondary",
2258
2482
  primaryContentType,
2259
2483
  style,
2484
+ intent,
2260
2485
  outputIndex,
2261
2486
  outputCountForType,
2262
2487
  articleReferenceMarkdown,
@@ -2286,6 +2511,7 @@ async function writeSingleShotContent({
2286
2511
  role,
2287
2512
  primaryContentType,
2288
2513
  style,
2514
+ intent,
2289
2515
  outputIndex,
2290
2516
  outputCountForType,
2291
2517
  contentBrief,
@@ -2333,13 +2559,12 @@ var OUTRO_PARAGRAPH_COUNTS = {
2333
2559
  medium: "2 to 3",
2334
2560
  large: "3 to 5"
2335
2561
  };
2336
- function buildSystemInstruction(base, style, contentTypes, targetLength) {
2562
+ function buildSystemInstruction(base, style, intent, contentTypes, targetLengthWords) {
2337
2563
  return [
2338
2564
  base,
2339
- buildWritingFrameworkInstruction(),
2340
- buildStyleDirective(style),
2565
+ buildArticleSectionGuideInstruction(style, intent, "article"),
2341
2566
  buildRunContextDirective(contentTypes),
2342
- buildTargetLengthDirective("article", targetLength)
2567
+ buildTargetLengthDirective("article", targetLengthWords)
2343
2568
  ].join(" ");
2344
2569
  }
2345
2570
  function sharedPlanContext(plan) {
@@ -2363,14 +2588,16 @@ function sharedDraftContext(articleSoFar) {
2363
2588
  normalized
2364
2589
  ].join("\n");
2365
2590
  }
2366
- function buildIntroMessages(plan, style, contentTypes, targetLength) {
2591
+ function buildIntroMessages(plan, style, intent, contentTypes, targetLengthWords, introTargetWords) {
2367
2592
  const baseSystemInstruction = buildSystemInstruction(
2368
2593
  "You write polished editorial prose for Markdown articles. Return only the prose body with no heading and no code fences.",
2369
2594
  style,
2595
+ intent,
2370
2596
  contentTypes,
2371
- targetLength
2597
+ targetLengthWords
2372
2598
  );
2373
- const paragraphCount = INTRO_PARAGRAPH_COUNTS[targetLength] ?? INTRO_PARAGRAPH_COUNTS["medium"];
2599
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2600
+ const paragraphCount = INTRO_PARAGRAPH_COUNTS[targetLengthAlias] ?? INTRO_PARAGRAPH_COUNTS["medium"];
2374
2601
  return [
2375
2602
  {
2376
2603
  role: "system",
@@ -2384,20 +2611,23 @@ function buildIntroMessages(plan, style, contentTypes, targetLength) {
2384
2611
  `Write the article introduction using this brief: ${plan.introBrief}`,
2385
2612
  "Requirements:",
2386
2613
  `- ${paragraphCount} paragraphs.`,
2614
+ `- Target length: about ${introTargetWords} words.`,
2387
2615
  "- Hook the reader quickly.",
2388
2616
  "- Set up the argument and tone for the rest of the article."
2389
2617
  ].join("\n")
2390
2618
  }
2391
2619
  ];
2392
2620
  }
2393
- function buildSectionMessages(plan, section, articleSoFar, style, contentTypes, targetLength) {
2621
+ function buildSectionMessages(plan, section, articleSoFar, style, intent, contentTypes, targetLengthWords, sectionTargetWords) {
2394
2622
  const baseSystemInstruction = buildSystemInstruction(
2395
2623
  "You write in-depth Markdown article sections. Return only the prose body for the section, with no heading and no code fences.",
2396
2624
  style,
2625
+ intent,
2397
2626
  contentTypes,
2398
- targetLength
2627
+ targetLengthWords
2399
2628
  );
2400
- const paragraphCount = SECTION_PARAGRAPH_COUNTS[targetLength] ?? SECTION_PARAGRAPH_COUNTS["medium"];
2629
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2630
+ const paragraphCount = SECTION_PARAGRAPH_COUNTS[targetLengthAlias] ?? SECTION_PARAGRAPH_COUNTS["medium"];
2401
2631
  return [
2402
2632
  {
2403
2633
  role: "system",
@@ -2414,6 +2644,7 @@ function buildSectionMessages(plan, section, articleSoFar, style, contentTypes,
2414
2644
  `Section focus: ${section.description}`,
2415
2645
  "Requirements:",
2416
2646
  `- ${paragraphCount} paragraphs.`,
2647
+ `- Target length: about ${sectionTargetWords} words.`,
2417
2648
  "- Be concrete and specific.",
2418
2649
  "- Continue naturally from the article draft so far without rehashing prior sections.",
2419
2650
  "- Use short Markdown lists only if they materially improve clarity."
@@ -2421,14 +2652,16 @@ function buildSectionMessages(plan, section, articleSoFar, style, contentTypes,
2421
2652
  }
2422
2653
  ];
2423
2654
  }
2424
- function buildOutroMessages(plan, style, contentTypes, targetLength) {
2655
+ function buildOutroMessages(plan, style, intent, contentTypes, targetLengthWords, outroTargetWords) {
2425
2656
  const baseSystemInstruction = buildSystemInstruction(
2426
2657
  "You write polished editorial conclusions for Markdown articles. Return only the prose body with no heading and no code fences.",
2427
2658
  style,
2659
+ intent,
2428
2660
  contentTypes,
2429
- targetLength
2661
+ targetLengthWords
2430
2662
  );
2431
- const paragraphCount = OUTRO_PARAGRAPH_COUNTS[targetLength] ?? OUTRO_PARAGRAPH_COUNTS["medium"];
2663
+ const targetLengthAlias = resolveTargetLengthAlias(targetLengthWords);
2664
+ const paragraphCount = OUTRO_PARAGRAPH_COUNTS[targetLengthAlias] ?? OUTRO_PARAGRAPH_COUNTS["medium"];
2432
2665
  return [
2433
2666
  {
2434
2667
  role: "system",
@@ -2442,6 +2675,7 @@ function buildOutroMessages(plan, style, contentTypes, targetLength) {
2442
2675
  `Write the article conclusion using this brief: ${plan.outroBrief}`,
2443
2676
  "Requirements:",
2444
2677
  `- ${paragraphCount} paragraphs.`,
2678
+ `- Target length: about ${outroTargetWords} words.`,
2445
2679
  "- Synthesize the main argument.",
2446
2680
  "- End with a strong, thoughtful closing line."
2447
2681
  ].join("\n")
@@ -2459,13 +2693,16 @@ async function writeArticleSections({
2459
2693
  onLlmMetrics,
2460
2694
  onInteraction
2461
2695
  }) {
2696
+ const wordBudgets = allocateWordBudgets(settings.targetLength, plan.sections.length);
2462
2697
  onSectionStart?.("Writing introduction");
2463
2698
  const intro = dryRun || !openRouter ? dryRunIntro(plan) : await openRouter.requestText({
2464
2699
  messages: buildIntroMessages(
2465
2700
  plan,
2466
2701
  settings.style,
2702
+ settings.intent,
2467
2703
  settings.contentTargets.map((target) => target.contentType),
2468
- settings.targetLength
2704
+ settings.targetLength,
2705
+ wordBudgets.intro
2469
2706
  ),
2470
2707
  settings,
2471
2708
  interactionContext: {
@@ -2487,8 +2724,10 @@ async function writeArticleSections({
2487
2724
  section,
2488
2725
  buildArticleSoFarContext(intro, sections),
2489
2726
  settings.style,
2727
+ settings.intent,
2490
2728
  settings.contentTargets.map((target) => target.contentType),
2491
- settings.targetLength
2729
+ settings.targetLength,
2730
+ wordBudgets.sections[index] ?? wordBudgets.sections[wordBudgets.sections.length - 1] ?? 150
2492
2731
  ),
2493
2732
  settings,
2494
2733
  interactionContext: {
@@ -2510,8 +2749,10 @@ async function writeArticleSections({
2510
2749
  messages: buildOutroMessages(
2511
2750
  plan,
2512
2751
  settings.style,
2752
+ settings.intent,
2513
2753
  settings.contentTargets.map((target) => target.contentType),
2514
- settings.targetLength
2754
+ settings.targetLength,
2755
+ wordBudgets.outro
2515
2756
  ),
2516
2757
  settings,
2517
2758
  interactionContext: {
@@ -2547,6 +2788,23 @@ function dryRunOutro(plan) {
2547
2788
  "What matters is a workflow that can repeatedly transform a promising idea into a piece that is clear, useful, and worth reading."
2548
2789
  ].join("\n\n");
2549
2790
  }
2791
+ function allocateWordBudgets(totalTargetWords, sectionCount) {
2792
+ const normalizedTotal = Number.isFinite(totalTargetWords) && totalTargetWords > 0 ? Math.round(totalTargetWords) : 900;
2793
+ const normalizedSectionCount = Math.max(1, sectionCount);
2794
+ const intro = Math.max(80, Math.round(normalizedTotal * 0.15));
2795
+ const outro = Math.max(80, Math.round(normalizedTotal * 0.1));
2796
+ const remainingForSections = Math.max(normalizedSectionCount * 120, normalizedTotal - intro - outro);
2797
+ const baseSectionWords = Math.floor(remainingForSections / normalizedSectionCount);
2798
+ let remainder = remainingForSections - baseSectionWords * normalizedSectionCount;
2799
+ const sections = Array.from({ length: normalizedSectionCount }, () => {
2800
+ const next = baseSectionWords + (remainder > 0 ? 1 : 0);
2801
+ if (remainder > 0) {
2802
+ remainder -= 1;
2803
+ }
2804
+ return Math.max(120, next);
2805
+ });
2806
+ return { intro, sections, outro };
2807
+ }
2550
2808
  function buildArticleSoFarContext(intro, sections) {
2551
2809
  const parts = ["## Introduction", intro.trim()];
2552
2810
  for (const section of sections) {
@@ -2594,6 +2852,14 @@ var ReplicateClient = class {
2594
2852
  const backoff = backoffMs(attempt);
2595
2853
  retries += 1;
2596
2854
  retryBackoffMs += backoff;
2855
+ options.onRetry?.({
2856
+ attempts,
2857
+ retries,
2858
+ retryBackoffMs,
2859
+ backoffMs: backoff,
2860
+ errorMessage: lastError.message,
2861
+ modelId: model
2862
+ });
2597
2863
  await wait(backoff);
2598
2864
  continue;
2599
2865
  }
@@ -2617,7 +2883,7 @@ function wait(ms) {
2617
2883
 
2618
2884
  // src/images/renderImages.ts
2619
2885
  import { writeFile as writeFile4 } from "fs/promises";
2620
- import path5 from "path";
2886
+ import path6 from "path";
2621
2887
 
2622
2888
  // src/llm/prompts/imagePrompt.ts
2623
2889
  var imagePromptSchema = {
@@ -3296,14 +3562,15 @@ async function renderExpandedImages({
3296
3562
  dryRun,
3297
3563
  onProgress,
3298
3564
  onRenderComplete,
3299
- onInteraction
3565
+ onInteraction,
3566
+ onRetry
3300
3567
  }) {
3301
3568
  const renderedImages = [];
3302
3569
  for (let index = 0; index < prompts.length; index += 1) {
3303
3570
  const prompt = prompts[index];
3304
3571
  onProgress?.(`Rendering image ${index + 1}/${prompts.length} with ${settings.t2i.modelId}`);
3305
3572
  const fileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.${resolveOutputFormat(settings)}`;
3306
- const outputPath = path5.join(assetDir, fileName);
3573
+ const outputPath = path6.join(assetDir, fileName);
3307
3574
  if (dryRun || !replicate) {
3308
3575
  const dryRunStartMs = Date.now();
3309
3576
  await writeFile4(outputPath, `Placeholder image for: ${prompt.prompt}
@@ -3361,6 +3628,14 @@ async function renderExpandedImages({
3361
3628
  runAttempts = metrics.attempts;
3362
3629
  runRetries = metrics.retries;
3363
3630
  runRetryBackoffMs = metrics.retryBackoffMs;
3631
+ },
3632
+ onRetry(event) {
3633
+ onRetry?.({
3634
+ imageId: prompt.id,
3635
+ kind: prompt.kind,
3636
+ retries: event.retries,
3637
+ errorMessage: event.errorMessage
3638
+ });
3364
3639
  }
3365
3640
  });
3366
3641
  const bytes = await normalizeReplicateOutput(output);
@@ -4067,7 +4342,7 @@ ${body.join("\n").trim()}
4067
4342
 
4068
4343
  // src/pipeline/sessionStore.ts
4069
4344
  import { mkdir as mkdir4, readFile as readFile5, rm as rm2, writeFile as writeFile5 } from "fs/promises";
4070
- import path6 from "path";
4345
+ import path7 from "path";
4071
4346
  import { z as z6 } from "zod";
4072
4347
  var STAGE_IDS = ["shared-brief", "planning", "sections", "image-prompts", "images", "output", "links"];
4073
4348
  var generatedArticleSectionSchema = z6.object({
@@ -4094,7 +4369,8 @@ var linksResultSchema = z6.object({
4094
4369
  fileId: z6.string().min(1),
4095
4370
  contentType: z6.string().min(1),
4096
4371
  markdownPath: z6.string().min(1),
4097
- links: z6.array(linkEntrySchema)
4372
+ links: z6.array(linkEntrySchema),
4373
+ customLinks: z6.array(linkEntrySchema).default([])
4098
4374
  });
4099
4375
  var pipelineArtifactSummarySchema = z6.object({
4100
4376
  title: z6.string().min(1),
@@ -4143,10 +4419,10 @@ var writeSessionStateSchema = z6.object({
4143
4419
  artifact: pipelineArtifactSummarySchema.nullable()
4144
4420
  });
4145
4421
  function resolveWriteRoot(workingDir) {
4146
- return path6.join(workingDir, ".ideon", "write");
4422
+ return path7.join(workingDir, ".ideon", "write");
4147
4423
  }
4148
4424
  function resolveStateFilePath(workingDir) {
4149
- return path6.join(resolveWriteRoot(workingDir), "state.json");
4425
+ return path7.join(resolveWriteRoot(workingDir), "state.json");
4150
4426
  }
4151
4427
  async function startFreshWriteSession(seed, workingDir = process.cwd()) {
4152
4428
  const writeRoot = resolveWriteRoot(workingDir);
@@ -4196,7 +4472,7 @@ async function saveWriteSession(state, workingDir = process.cwd()) {
4196
4472
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4197
4473
  });
4198
4474
  const statePath = resolveStateFilePath(workingDir);
4199
- await mkdir4(path6.dirname(statePath), { recursive: true });
4475
+ await mkdir4(path7.dirname(statePath), { recursive: true });
4200
4476
  await writeFile5(statePath, `${JSON.stringify(next, null, 2)}
4201
4477
  `, "utf8");
4202
4478
  return next;
@@ -4289,12 +4565,18 @@ async function runPipelineShell(input, options = {}) {
4289
4565
  const stages = createInitialStages({ isArticlePrimary });
4290
4566
  options.onUpdate?.(cloneStages(stages));
4291
4567
  const dryRun = options.dryRun ?? false;
4292
- const shouldEnrichLinks = options.enrichLinks ?? true;
4568
+ const shouldEnrichLinks = options.enrichLinks ?? false;
4293
4569
  const runMode = options.runMode ?? "fresh";
4294
4570
  const workingDir = options.workingDir ?? process.cwd();
4571
+ const pipelineCustomLinkRaws = options.customLinks ?? [];
4572
+ const pipelineUnlinks = options.unlinks ?? [];
4573
+ const pipelineMaxLinks = options.maxLinks;
4295
4574
  const outputPaths = resolveOutputPaths(input.config.settings, workingDir);
4296
4575
  const hasArticlePrimary = isArticlePrimary;
4297
4576
  const stageTracking = /* @__PURE__ */ new Map();
4577
+ const stageRetryState = /* @__PURE__ */ new Map();
4578
+ const llmOperationRetryState = /* @__PURE__ */ new Map();
4579
+ const imageOperationRetryState = /* @__PURE__ */ new Map();
4298
4580
  stageTracking.set("shared-brief", {
4299
4581
  startedAtMs: runStartedAtMs,
4300
4582
  endedAtMs: null,
@@ -4309,6 +4591,41 @@ async function runPipelineShell(input, options = {}) {
4309
4591
  const llmInteractions = [];
4310
4592
  const t2iInteractions = [];
4311
4593
  let writeSession;
4594
+ const applyRetryUpdate = (stageId, retryIncrement, errorMessage) => {
4595
+ if (retryIncrement <= 0) {
4596
+ return;
4597
+ }
4598
+ const stageIndex = stages.findIndex((stage) => stage.id === stageId);
4599
+ if (stageIndex < 0) {
4600
+ return;
4601
+ }
4602
+ const existing = stageRetryState.get(stageId) ?? { retries: 0, lastError: null };
4603
+ const next = {
4604
+ retries: existing.retries + retryIncrement,
4605
+ lastError: errorMessage && errorMessage.trim().length > 0 ? errorMessage : existing.lastError
4606
+ };
4607
+ stageRetryState.set(stageId, next);
4608
+ stages[stageIndex] = {
4609
+ ...stages[stageIndex],
4610
+ retryCount: next.retries,
4611
+ lastRetryError: next.lastError ?? void 0
4612
+ };
4613
+ options.onUpdate?.(cloneStages(stages));
4614
+ };
4615
+ const onLlmInteraction = (interaction) => {
4616
+ llmInteractions.push(interaction);
4617
+ const stageId = asWriteStageId(interaction.stageId);
4618
+ if (!stageId) {
4619
+ return;
4620
+ }
4621
+ const previousRetries = llmOperationRetryState.get(interaction.operationId) ?? 0;
4622
+ if (interaction.retries <= previousRetries) {
4623
+ return;
4624
+ }
4625
+ const retryIncrement = interaction.retries - previousRetries;
4626
+ llmOperationRetryState.set(interaction.operationId, interaction.retries);
4627
+ applyRetryUpdate(stageId, retryIncrement, interaction.errorMessage);
4628
+ };
4312
4629
  if (runMode === "fresh") {
4313
4630
  writeSession = await startFreshWriteSession(
4314
4631
  {
@@ -4360,7 +4677,7 @@ async function runPipelineShell(input, options = {}) {
4360
4677
  openRouter,
4361
4678
  dryRun,
4362
4679
  onInteraction(interaction) {
4363
- llmInteractions.push(interaction);
4680
+ onLlmInteraction(interaction);
4364
4681
  },
4365
4682
  onLlmMetrics(metrics) {
4366
4683
  recordLlmMetrics(stageTracking, "shared-brief", metrics);
@@ -4414,7 +4731,7 @@ async function runPipelineShell(input, options = {}) {
4414
4731
  openRouter,
4415
4732
  dryRun,
4416
4733
  onInteraction(interaction) {
4417
- llmInteractions.push(interaction);
4734
+ onLlmInteraction(interaction);
4418
4735
  },
4419
4736
  onLlmMetrics(metrics) {
4420
4737
  recordLlmMetrics(stageTracking, "planning", metrics);
@@ -4477,7 +4794,7 @@ async function runPipelineShell(input, options = {}) {
4477
4794
  openRouter,
4478
4795
  dryRun,
4479
4796
  onInteraction(interaction) {
4480
- llmInteractions.push(interaction);
4797
+ onLlmInteraction(interaction);
4481
4798
  },
4482
4799
  onLlmMetrics(phase, metrics, sectionIndex) {
4483
4800
  recordLlmMetrics(stageTracking, "sections", metrics);
@@ -4590,7 +4907,7 @@ async function runPipelineShell(input, options = {}) {
4590
4907
  openRouter,
4591
4908
  dryRun,
4592
4909
  onInteraction(interaction) {
4593
- llmInteractions.push(interaction);
4910
+ onLlmInteraction(interaction);
4594
4911
  },
4595
4912
  onPromptComplete(metrics) {
4596
4913
  imagePromptCalls.push({
@@ -4676,6 +4993,7 @@ async function runPipelineShell(input, options = {}) {
4676
4993
  role: "primary",
4677
4994
  primaryContentType: primaryTarget.contentType,
4678
4995
  style: input.config.settings.style,
4996
+ intent: input.config.settings.intent,
4679
4997
  outputIndex: 1,
4680
4998
  outputCountForType: 1,
4681
4999
  articleReferenceMarkdown: void 0,
@@ -4684,7 +5002,7 @@ async function runPipelineShell(input, options = {}) {
4684
5002
  openRouter,
4685
5003
  dryRun,
4686
5004
  onInteraction(interaction) {
4687
- llmInteractions.push(interaction);
5005
+ onLlmInteraction(interaction);
4688
5006
  },
4689
5007
  onLlmMetrics(metrics) {
4690
5008
  recordLlmMetrics(stageTracking, "sections", metrics);
@@ -4728,12 +5046,12 @@ async function runPipelineShell(input, options = {}) {
4728
5046
  options.onUpdate?.(cloneStages(stages));
4729
5047
  }
4730
5048
  const baseSlug = plan?.slug ?? slugifyIdea(input.idea);
4731
- const generationDir = path7.join(
5049
+ const generationDir = path8.join(
4732
5050
  writeSession.outputPaths.markdownOutputDir,
4733
5051
  buildGenerationDirectoryName(baseSlug)
4734
5052
  );
4735
5053
  await mkdir5(generationDir, { recursive: true });
4736
- const jobDefinitionPath = path7.join(generationDir, "job.json");
5054
+ const jobDefinitionPath = path8.join(generationDir, "job.json");
4737
5055
  await writeJsonFile(
4738
5056
  jobDefinitionPath,
4739
5057
  buildRunJobDefinition({
@@ -4746,7 +5064,7 @@ async function runPipelineShell(input, options = {}) {
4746
5064
  })
4747
5065
  );
4748
5066
  const primaryFilePrefix = toFilePrefix(primaryTarget.contentType);
4749
- const primaryMarkdownPath = path7.join(generationDir, `${primaryFilePrefix}-1.md`);
5067
+ const primaryMarkdownPath = path8.join(generationDir, `${primaryFilePrefix}-1.md`);
4750
5068
  const sharedAssetDir = generationDir;
4751
5069
  if (hasArticlePrimary) {
4752
5070
  if (imageArtifacts) {
@@ -4788,6 +5106,15 @@ async function runPipelineShell(input, options = {}) {
4788
5106
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
4789
5107
  addStageRetries(stageTracking, "images", metrics.retries);
4790
5108
  },
5109
+ onRetry(event) {
5110
+ const operationKey = `images:${event.imageId}`;
5111
+ const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5112
+ if (event.retries <= previousRetries) {
5113
+ return;
5114
+ }
5115
+ imageOperationRetryState.set(operationKey, event.retries);
5116
+ applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5117
+ },
4791
5118
  onProgress(detail) {
4792
5119
  stages[4] = {
4793
5120
  ...stages[4],
@@ -4872,6 +5199,15 @@ async function runPipelineShell(input, options = {}) {
4872
5199
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
4873
5200
  addStageRetries(stageTracking, "images", metrics.retries);
4874
5201
  },
5202
+ onRetry(event) {
5203
+ const operationKey = `images:${event.imageId}`;
5204
+ const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5205
+ if (event.retries <= previousRetries) {
5206
+ return;
5207
+ }
5208
+ imageOperationRetryState.set(operationKey, event.retries);
5209
+ applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5210
+ },
4875
5211
  onProgress(detail) {
4876
5212
  stages[4] = {
4877
5213
  ...stages[4],
@@ -4969,12 +5305,13 @@ async function runPipelineShell(input, options = {}) {
4969
5305
  })
4970
5306
  };
4971
5307
  options.onUpdate?.(cloneStages(stages));
4972
- const markdownPath = path7.join(generationDir, `${output.filePrefix}-${output.index}.md`);
5308
+ const markdownPath = path8.join(generationDir, `${output.filePrefix}-${output.index}.md`);
4973
5309
  try {
4974
5310
  const content = await writeSingleShotContent({
4975
5311
  idea: input.idea,
4976
5312
  contentType: output.contentType,
4977
5313
  style: input.config.settings.style,
5314
+ intent: input.config.settings.intent,
4978
5315
  outputIndex: output.index,
4979
5316
  outputCountForType: output.outputCountForType,
4980
5317
  articleReferenceMarkdown: primaryMarkdownTemplate ?? void 0,
@@ -4985,7 +5322,7 @@ async function runPipelineShell(input, options = {}) {
4985
5322
  role: "secondary",
4986
5323
  primaryContentType: primaryTarget.contentType,
4987
5324
  onInteraction(interaction) {
4988
- llmInteractions.push(interaction);
5325
+ onLlmInteraction(interaction);
4989
5326
  },
4990
5327
  onLlmMetrics(metrics) {
4991
5328
  recordLlmMetrics(stageTracking, "output", metrics);
@@ -5030,7 +5367,7 @@ async function runPipelineShell(input, options = {}) {
5030
5367
  ...item,
5031
5368
  status: "succeeded",
5032
5369
  detail: "Saved markdown output.",
5033
- summary: path7.basename(markdownPath),
5370
+ summary: path8.basename(markdownPath),
5034
5371
  analytics: {
5035
5372
  durationMs: itemDurationMs,
5036
5373
  costUsd: knownItemCost.usd,
@@ -5086,7 +5423,7 @@ async function runPipelineShell(input, options = {}) {
5086
5423
  stages[6] = {
5087
5424
  ...stages[6],
5088
5425
  status: "succeeded",
5089
- detail: "Skipped link enrichment (--no-enrich-links).",
5426
+ detail: "Skipped link enrichment (enable with --enrich-links).",
5090
5427
  summary: "Link enrichment disabled for this run",
5091
5428
  stageAnalytics: snapshotStageAnalytics(stageTracking, "links")
5092
5429
  };
@@ -5103,15 +5440,19 @@ async function runPipelineShell(input, options = {}) {
5103
5440
  options.onUpdate?.(cloneStages(stages));
5104
5441
  } else if (linksResult) {
5105
5442
  const linksByFileId = new Map(linksResult.map((item) => [item.fileId, item.links]));
5106
- linksResult = eligibleOutputsForLinks.map((output) => ({
5443
+ const customLinksByFileId = new Map(linksResult.map((item) => [item.fileId, item.customLinks]));
5444
+ const resumedLinks = eligibleOutputsForLinks.map((output) => ({
5107
5445
  fileId: output.fileId,
5108
5446
  contentType: output.contentType,
5109
5447
  markdownPath: output.markdownPath,
5110
- links: linksByFileId.get(output.fileId) ?? []
5448
+ links: linksByFileId.get(output.fileId) ?? [],
5449
+ customLinks: customLinksByFileId.get(output.fileId) ?? []
5111
5450
  }));
5112
- for (const item of linksResult) {
5451
+ linksResult = resumedLinks;
5452
+ for (const item of resumedLinks) {
5113
5453
  await writeLinksFile(item.markdownPath, {
5114
- version: 1,
5454
+ version: 2,
5455
+ customLinks: item.customLinks,
5115
5456
  links: item.links
5116
5457
  });
5117
5458
  }
@@ -5120,7 +5461,7 @@ async function runPipelineShell(input, options = {}) {
5120
5461
  ...stages[6],
5121
5462
  status: "succeeded",
5122
5463
  detail: "Reused saved link metadata from .ideon/write.",
5123
- summary: `${linksResult.reduce((sum, item) => sum + item.links.length, 0)} links`,
5464
+ summary: `${resumedLinks.reduce((sum, item) => sum + item.links.length, 0)} links`,
5124
5465
  items: (stages[6].items ?? []).map((item) => ({
5125
5466
  ...item,
5126
5467
  status: "succeeded",
@@ -5138,8 +5479,10 @@ async function runPipelineShell(input, options = {}) {
5138
5479
  openRouter,
5139
5480
  settings: input.config.settings,
5140
5481
  dryRun,
5482
+ customLinks: parsePipelineCustomLinks(pipelineCustomLinkRaws, pipelineUnlinks),
5483
+ maxLinks: pipelineMaxLinks ?? resolveDefaultMaxLinks(input.config.settings.targetLength),
5141
5484
  onInteraction(interaction) {
5142
- llmInteractions.push(interaction);
5485
+ onLlmInteraction(interaction);
5143
5486
  },
5144
5487
  onLlmMetrics(fileId, metrics) {
5145
5488
  recordLlmMetrics(stageTracking, "links", metrics);
@@ -5182,7 +5525,8 @@ async function runPipelineShell(input, options = {}) {
5182
5525
  costSource
5183
5526
  });
5184
5527
  await writeLinksFile(item.markdownPath, {
5185
- version: 1,
5528
+ version: 2,
5529
+ customLinks: item.customLinks,
5186
5530
  links: item.links
5187
5531
  });
5188
5532
  }
@@ -5238,8 +5582,8 @@ async function runPipelineShell(input, options = {}) {
5238
5582
  llmCalls: llmInteractions,
5239
5583
  t2iCalls: t2iInteractions
5240
5584
  };
5241
- const analyticsPath = path7.join(generationDir, "generation.analytics.json");
5242
- const interactionsPath = path7.join(generationDir, "model.interactions.json");
5585
+ const analyticsPath = path8.join(generationDir, "generation.analytics.json");
5586
+ const interactionsPath = path8.join(generationDir, "model.interactions.json");
5243
5587
  await writeJsonFile(analyticsPath, analytics);
5244
5588
  await writeJsonFile(interactionsPath, interactions);
5245
5589
  const primaryMarkdownPathForArtifact = markdownPaths[0] ?? primaryMarkdownPath;
@@ -5547,7 +5891,6 @@ function toFilePrefix(contentType) {
5547
5891
  if (contentType === "reddit-post") return "reddit";
5548
5892
  if (contentType === "linkedin-post") return "linkedin";
5549
5893
  if (contentType === "newsletter") return "newsletter";
5550
- if (contentType === "landing-page-copy") return "landing";
5551
5894
  return contentType.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "content";
5552
5895
  }
5553
5896
  function getPrimaryTarget(contentTargets) {
@@ -5643,6 +5986,24 @@ function asWriteStageId(stageId) {
5643
5986
  }
5644
5987
  return null;
5645
5988
  }
5989
+ function parsePipelineCustomLinks(rawLinks, unlinks) {
5990
+ const result = /* @__PURE__ */ new Map();
5991
+ for (const raw of rawLinks) {
5992
+ const separatorIndex = raw.indexOf("->");
5993
+ if (separatorIndex < 0) {
5994
+ continue;
5995
+ }
5996
+ const expression = raw.slice(0, separatorIndex).trim();
5997
+ const url = raw.slice(separatorIndex + 2).trim();
5998
+ if (expression && url) {
5999
+ result.set(expression.toLowerCase(), { expression, url, title: null });
6000
+ }
6001
+ }
6002
+ for (const expr of unlinks) {
6003
+ result.delete(expr.trim().toLowerCase());
6004
+ }
6005
+ return Array.from(result.values());
6006
+ }
5646
6007
 
5647
6008
  // src/cli/commands/writeTargetSpecs.ts
5648
6009
  function parseTargetSpec(spec) {
@@ -5931,6 +6292,268 @@ async function runMcpServeCommand() {
5931
6292
  await startIdeonMcpServer();
5932
6293
  }
5933
6294
 
6295
+ // src/cli/commands/links.ts
6296
+ import { readFile as readFile6, stat as stat3 } from "fs/promises";
6297
+ import path9 from "path";
6298
+ async function runLinksCommand(options, dependencies = {}) {
6299
+ const slug = normalizeSlug2(options.slug);
6300
+ const mode = normalizeMode(options.mode);
6301
+ const cwd2 = dependencies.cwd ?? process.cwd();
6302
+ const log = dependencies.log ?? ((message) => console.log(message));
6303
+ const resolved = await resolveRunInput({
6304
+ idea: `Enrich links for ${slug}`
6305
+ });
6306
+ const markdownPath = await resolveMarkdownPathForSlug2(resolved.config.settings, slug, cwd2);
6307
+ const frontmatter = await readFrontmatter(markdownPath);
6308
+ const fileId = path9.parse(markdownPath).name;
6309
+ const articleTitle = frontmatter.title ?? toTitleFromSlug(slug);
6310
+ const articleDescription = frontmatter.description ?? "";
6311
+ const openRouterApiKey = resolved.config.secrets.openRouterApiKey;
6312
+ if (!openRouterApiKey) {
6313
+ throw new ReportedError(
6314
+ "Missing OpenRouter API key. Run `ideon settings` to configure credentials or set IDEON_OPENROUTER_API_KEY."
6315
+ );
6316
+ }
6317
+ const openRouter = new OpenRouterClient(openRouterApiKey);
6318
+ const linksPath = resolveLinksPath(markdownPath);
6319
+ const existing = await readExistingLinks(linksPath);
6320
+ const updatedCustomLinks = resolveCustomLinks(existing?.customLinks ?? [], options.links ?? [], options.unlinks ?? []);
6321
+ const effectiveMaxLinks = options.maxLinks;
6322
+ const linksResult = await enrichLinks({
6323
+ markdownFiles: [{ markdownPath, fileId, contentType: "article" }],
6324
+ articleTitle,
6325
+ articleDescription,
6326
+ openRouter,
6327
+ settings: resolved.config.settings,
6328
+ dryRun: false,
6329
+ customLinks: updatedCustomLinks,
6330
+ maxLinks: effectiveMaxLinks,
6331
+ onItemProgress(event) {
6332
+ logProgress(event, log);
6333
+ }
6334
+ });
6335
+ const generatedLinks = linksResult[0]?.links ?? [];
6336
+ const mergedGeneratedLinks = mode === "append" ? mergeLinks(existing?.links ?? [], generatedLinks) : generatedLinks;
6337
+ const appendedCount = Math.max(0, mergedGeneratedLinks.length - (existing?.links.length ?? 0));
6338
+ await writeLinksFile(markdownPath, {
6339
+ version: 2,
6340
+ customLinks: updatedCustomLinks,
6341
+ links: mergedGeneratedLinks
6342
+ });
6343
+ const relativeMarkdownPath = formatRelativePath2(cwd2, markdownPath);
6344
+ const relativeLinksPath = formatRelativePath2(cwd2, linksPath);
6345
+ if (mode === "fresh") {
6346
+ const replaced = existing ? "Replaced existing links." : "Created links sidecar.";
6347
+ log(`Enriched links for "${slug}".`);
6348
+ log(`${replaced} Saved ${generatedLinks.length} generated + ${updatedCustomLinks.length} custom links to ${relativeLinksPath} (${relativeMarkdownPath}).`);
6349
+ return;
6350
+ }
6351
+ const baseCount = existing?.links.length ?? 0;
6352
+ const verb = existing ? "Appended and deduplicated links." : "Created links sidecar.";
6353
+ log(`Enriched links for "${slug}".`);
6354
+ log(`${verb} Base ${baseCount}, added ${appendedCount}, total ${mergedGeneratedLinks.length} generated + ${updatedCustomLinks.length} custom in ${relativeLinksPath} (${relativeMarkdownPath}).`);
6355
+ }
6356
+ function normalizeMode(rawMode) {
6357
+ const normalized = rawMode.trim().toLowerCase();
6358
+ if (normalized === "fresh" || normalized === "append") {
6359
+ return normalized;
6360
+ }
6361
+ throw new ReportedError(`Unsupported --mode value "${rawMode}". Use "fresh" or "append".`);
6362
+ }
6363
+ function normalizeSlug2(rawSlug) {
6364
+ const slug = rawSlug.trim();
6365
+ if (!slug) {
6366
+ throw new ReportedError("Slug cannot be empty. Pass the generated article slug, for example `ideon links my-article-slug`.");
6367
+ }
6368
+ if (slug.toLowerCase().endsWith(".md")) {
6369
+ throw new ReportedError(`Expected a slug, not a markdown filename: ${slug}. Pass the slug without .md.`);
6370
+ }
6371
+ if (slug === "." || slug === ".." || /[/\\]/.test(slug)) {
6372
+ throw new ReportedError(`Invalid slug "${slug}". Pass the article slug only, without any path separators.`);
6373
+ }
6374
+ return slug;
6375
+ }
6376
+ async function resolveMarkdownPathForSlug2(settings, slug, cwd2) {
6377
+ const outputPaths = resolveOutputPaths(settings, cwd2);
6378
+ const directPath = path9.join(outputPaths.markdownOutputDir, `${slug}.md`);
6379
+ if (await isReadableFile(directPath)) {
6380
+ return directPath;
6381
+ }
6382
+ const markdownFiles = await listMarkdownFilesRecursively(outputPaths.markdownOutputDir);
6383
+ const matches = [];
6384
+ for (const candidate of markdownFiles) {
6385
+ if (path9.basename(candidate) === `${slug}.md`) {
6386
+ matches.push(candidate);
6387
+ continue;
6388
+ }
6389
+ const frontmatter = await readFrontmatter(candidate);
6390
+ if (frontmatter.slug === slug) {
6391
+ matches.push(candidate);
6392
+ }
6393
+ }
6394
+ if (matches.length === 0) {
6395
+ throw new ReportedError(
6396
+ `Could not find article "${slug}". Expected a markdown file in ${outputPaths.markdownOutputDir} with frontmatter slug "${slug}".`
6397
+ );
6398
+ }
6399
+ return newestPath(matches);
6400
+ }
6401
+ async function newestPath(paths) {
6402
+ let latestPath = paths[0];
6403
+ let latestMtime = 0;
6404
+ for (const candidate of paths) {
6405
+ const candidateStat = await stat3(candidate);
6406
+ if (candidateStat.mtimeMs >= latestMtime) {
6407
+ latestMtime = candidateStat.mtimeMs;
6408
+ latestPath = candidate;
6409
+ }
6410
+ }
6411
+ return latestPath;
6412
+ }
6413
+ async function readFrontmatter(markdownPath) {
6414
+ const markdown = await readFile6(markdownPath, "utf8");
6415
+ return parseFrontmatter(markdown);
6416
+ }
6417
+ function parseFrontmatter(markdown) {
6418
+ if (!markdown.startsWith("---\n")) {
6419
+ return { slug: null, title: null, description: null };
6420
+ }
6421
+ const frontmatterEnd = markdown.indexOf("\n---\n", 4);
6422
+ if (frontmatterEnd < 0) {
6423
+ return { slug: null, title: null, description: null };
6424
+ }
6425
+ const block = markdown.slice(4, frontmatterEnd);
6426
+ return {
6427
+ slug: parseFrontmatterValue(block, "slug"),
6428
+ title: parseFrontmatterValue(block, "title"),
6429
+ description: parseFrontmatterValue(block, "description")
6430
+ };
6431
+ }
6432
+ function parseFrontmatterValue(block, key) {
6433
+ const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
6434
+ const match = block.match(pattern);
6435
+ if (!match || !match[1]) {
6436
+ return null;
6437
+ }
6438
+ const rawValue = match[1].trim();
6439
+ if (!rawValue) {
6440
+ return null;
6441
+ }
6442
+ if (rawValue.startsWith('"') && rawValue.endsWith('"') || rawValue.startsWith("'") && rawValue.endsWith("'")) {
6443
+ return rawValue.slice(1, -1);
6444
+ }
6445
+ return rawValue;
6446
+ }
6447
+ function toTitleFromSlug(slug) {
6448
+ return slug.split("-").filter((part) => part.length > 0).map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
6449
+ }
6450
+ async function isReadableFile(filePath) {
6451
+ try {
6452
+ const fileStat = await stat3(filePath);
6453
+ return fileStat.isFile();
6454
+ } catch {
6455
+ return false;
6456
+ }
6457
+ }
6458
+ async function readExistingLinks(linksPath) {
6459
+ try {
6460
+ const raw = await readFile6(linksPath, "utf8");
6461
+ const parsed = JSON.parse(raw);
6462
+ const links = Array.isArray(parsed.links) ? parsed.links.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6463
+ expression: entry.expression.trim(),
6464
+ url: entry.url.trim(),
6465
+ title: typeof entry.title === "string" ? entry.title : null
6466
+ })) : null;
6467
+ if (!links) {
6468
+ throw new ReportedError(`Invalid links sidecar format at ${linksPath}. Expected { version, links[] }.`);
6469
+ }
6470
+ const customLinks = Array.isArray(parsed.customLinks) ? parsed.customLinks.filter((entry) => isValidLinkEntry(entry)).map((entry) => ({
6471
+ expression: entry.expression.trim(),
6472
+ url: entry.url.trim(),
6473
+ title: typeof entry.title === "string" ? entry.title : null
6474
+ })) : [];
6475
+ return {
6476
+ version: typeof parsed.version === "number" ? parsed.version : 1,
6477
+ customLinks,
6478
+ links
6479
+ };
6480
+ } catch (error) {
6481
+ if (readErrorCode2(error) === "ENOENT") {
6482
+ return null;
6483
+ }
6484
+ if (error instanceof ReportedError) {
6485
+ throw error;
6486
+ }
6487
+ const message = error instanceof Error ? error.message : "Unknown links sidecar read error.";
6488
+ throw new ReportedError(`Failed to read existing links from ${linksPath}: ${message}`);
6489
+ }
6490
+ }
6491
+ function mergeLinks(existingLinks, generatedLinks) {
6492
+ const merged = [];
6493
+ const seen = /* @__PURE__ */ new Set();
6494
+ for (const entry of [...existingLinks, ...generatedLinks]) {
6495
+ const key = `${entry.expression.trim().toLowerCase()}::${entry.url.trim().toLowerCase()}`;
6496
+ if (seen.has(key)) {
6497
+ continue;
6498
+ }
6499
+ seen.add(key);
6500
+ merged.push(entry);
6501
+ }
6502
+ return merged;
6503
+ }
6504
+ function isValidLinkEntry(value2) {
6505
+ if (typeof value2 !== "object" || value2 === null) {
6506
+ return false;
6507
+ }
6508
+ const record = value2;
6509
+ return typeof record.expression === "string" && typeof record.url === "string" && (typeof record.title === "string" || record.title === null || record.title === void 0);
6510
+ }
6511
+ function readErrorCode2(error) {
6512
+ if (typeof error !== "object" || error === null || !("code" in error)) {
6513
+ return null;
6514
+ }
6515
+ const code = error.code;
6516
+ return typeof code === "string" ? code : null;
6517
+ }
6518
+ function formatRelativePath2(cwd2, targetPath) {
6519
+ const relativePath = path9.relative(cwd2, targetPath);
6520
+ return relativePath.length > 0 ? relativePath : targetPath;
6521
+ }
6522
+ function logProgress(event, log) {
6523
+ if (event.phase === "resolving-expression" || event.phase === "selecting-expressions") {
6524
+ return;
6525
+ }
6526
+ log(event.detail);
6527
+ }
6528
+ function parseCustomLinkFlag(raw) {
6529
+ const separatorIndex = raw.indexOf("->");
6530
+ if (separatorIndex < 0) {
6531
+ throw new ReportedError(`Invalid --link value "${raw}". Expected format: "expression->url".`);
6532
+ }
6533
+ const expression = raw.slice(0, separatorIndex).trim();
6534
+ const url = raw.slice(separatorIndex + 2).trim();
6535
+ if (!expression) {
6536
+ throw new ReportedError(`Invalid --link value "${raw}": expression (left side of ->) cannot be empty.`);
6537
+ }
6538
+ if (!url) {
6539
+ throw new ReportedError(`Invalid --link value "${raw}": url (right side of ->) cannot be empty.`);
6540
+ }
6541
+ return { expression, url };
6542
+ }
6543
+ function resolveCustomLinks(existing, addRaw, removeExpressions) {
6544
+ const result = new Map(
6545
+ existing.map((entry) => [entry.expression.trim().toLowerCase(), entry])
6546
+ );
6547
+ for (const raw of addRaw) {
6548
+ const { expression, url } = parseCustomLinkFlag(raw);
6549
+ result.set(expression.toLowerCase(), { expression, url, title: null });
6550
+ }
6551
+ for (const expr of removeExpressions) {
6552
+ result.delete(expr.trim().toLowerCase());
6553
+ }
6554
+ return Array.from(result.values());
6555
+ }
6556
+
5934
6557
  // src/cli/commands/settings.tsx
5935
6558
  import { render } from "ink";
5936
6559
 
@@ -6303,14 +6926,14 @@ async function openSettings() {
6303
6926
  }
6304
6927
 
6305
6928
  // src/cli/commands/serve.ts
6306
- import path10 from "path";
6929
+ import path12 from "path";
6307
6930
  import { spawn } from "child_process";
6308
6931
 
6309
6932
  // src/server/previewHelpers.ts
6310
- import { readdir, stat as stat3, readFile as readFile6 } from "fs/promises";
6311
- import path8 from "path";
6933
+ import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
6934
+ import path10 from "path";
6312
6935
  var DEFAULT_PORT = 4173;
6313
- var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter", "landing-page-copy"];
6936
+ var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
6314
6937
  var FILE_PREFIX_TO_CONTENT_TYPE = {
6315
6938
  article: "article",
6316
6939
  blog: "blog-post",
@@ -6319,8 +6942,7 @@ var FILE_PREFIX_TO_CONTENT_TYPE = {
6319
6942
  x: "x-post",
6320
6943
  reddit: "reddit-post",
6321
6944
  linkedin: "linkedin-post",
6322
- newsletter: "newsletter",
6323
- landing: "landing-page-copy"
6945
+ newsletter: "newsletter"
6324
6946
  };
6325
6947
  var CONTENT_TYPE_LABELS = {
6326
6948
  article: "Article",
@@ -6329,8 +6951,7 @@ var CONTENT_TYPE_LABELS = {
6329
6951
  "x-post": "X Post",
6330
6952
  "reddit-post": "Reddit Post",
6331
6953
  "linkedin-post": "LinkedIn Post",
6332
- newsletter: "Newsletter",
6333
- "landing-page-copy": "Landing Page Copy"
6954
+ newsletter: "Newsletter"
6334
6955
  };
6335
6956
  function parsePort(portOption) {
6336
6957
  if (!portOption) {
@@ -6368,8 +6989,8 @@ function extractHeadingTitle(markdown) {
6368
6989
  }
6369
6990
  async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
6370
6991
  if (markdownPathArg) {
6371
- const resolved = path8.isAbsolute(markdownPathArg) ? markdownPathArg : path8.resolve(cwd2, markdownPathArg);
6372
- if (path8.extname(resolved).toLowerCase() !== ".md") {
6992
+ const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
6993
+ if (path10.extname(resolved).toLowerCase() !== ".md") {
6373
6994
  throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
6374
6995
  }
6375
6996
  await assertFileExists(resolved, "Could not find markdown file");
@@ -6387,7 +7008,7 @@ async function resolveLatestMarkdown(markdownOutputDir) {
6387
7008
  let latestPath = markdownCandidates[0];
6388
7009
  let latestMtime = 0;
6389
7010
  for (const candidate of markdownCandidates) {
6390
- const fileStat = await stat3(candidate);
7011
+ const fileStat = await stat4(candidate);
6391
7012
  if (fileStat.mtimeMs >= latestMtime) {
6392
7013
  latestMtime = fileStat.mtimeMs;
6393
7014
  latestPath = candidate;
@@ -6397,7 +7018,7 @@ async function resolveLatestMarkdown(markdownOutputDir) {
6397
7018
  }
6398
7019
  async function assertFileExists(filePath, errorPrefix) {
6399
7020
  try {
6400
- const fileStat = await stat3(filePath);
7021
+ const fileStat = await stat4(filePath);
6401
7022
  if (!fileStat.isFile()) {
6402
7023
  throw new Error(`${errorPrefix}: ${filePath}`);
6403
7024
  }
@@ -6411,9 +7032,9 @@ function extractCoverImageUrl(markdown) {
6411
7032
  return match?.[1] ?? null;
6412
7033
  }
6413
7034
  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");
7035
+ const markdown = await readFile7(markdownPath, "utf8");
7036
+ const fileStat = await stat4(markdownPath);
7037
+ const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
6417
7038
  const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
6418
7039
  const body = stripFrontmatter2(markdown);
6419
7040
  const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
@@ -6478,16 +7099,16 @@ async function listAllGenerations(markdownOutputDir) {
6478
7099
  return generations;
6479
7100
  }
6480
7101
  function deriveGenerationId(markdownPath, markdownOutputDir) {
6481
- const relative = path8.relative(markdownOutputDir, markdownPath);
6482
- const normalized = relative.split(path8.sep).join("/");
7102
+ const relative = path10.relative(markdownOutputDir, markdownPath);
7103
+ const normalized = relative.split(path10.sep).join("/");
6483
7104
  if (!normalized || normalized.startsWith("../")) {
6484
- return path8.basename(markdownPath, ".md");
7105
+ return path10.basename(markdownPath, ".md");
6485
7106
  }
6486
7107
  const segments = normalized.split("/").filter(Boolean);
6487
7108
  if (segments.length <= 1) {
6488
- return path8.basename(markdownPath, ".md");
7109
+ return path10.basename(markdownPath, ".md");
6489
7110
  }
6490
- return segments[0] ?? path8.basename(markdownPath, ".md");
7111
+ return segments[0] ?? path10.basename(markdownPath, ".md");
6491
7112
  }
6492
7113
  async function findMarkdownFiles(markdownOutputDir) {
6493
7114
  const files = [];
@@ -6504,7 +7125,7 @@ async function findMarkdownFiles(markdownOutputDir) {
6504
7125
  continue;
6505
7126
  }
6506
7127
  for (const entry of entries) {
6507
- const fullPath = path8.join(current, entry.name);
7128
+ const fullPath = path10.join(current, entry.name);
6508
7129
  if (entry.isDirectory()) {
6509
7130
  stack.push(fullPath);
6510
7131
  continue;
@@ -6518,7 +7139,7 @@ async function findMarkdownFiles(markdownOutputDir) {
6518
7139
  }
6519
7140
  function deriveOutputIdentity(markdownPath, markdownOutputDir) {
6520
7141
  const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
6521
- const fileBase = path8.basename(markdownPath, ".md");
7142
+ const fileBase = path10.basename(markdownPath, ".md");
6522
7143
  const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
6523
7144
  if (!parsed || !parsed[1] || !parsed[2]) {
6524
7145
  return {
@@ -6554,13 +7175,13 @@ function toContentTypeLabel(contentType) {
6554
7175
  }
6555
7176
  async function resolvePrimaryContentType(outputs) {
6556
7177
  const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
6557
- const generationDir = path8.dirname(outputs[0]?.sourcePath ?? "");
7178
+ const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
6558
7179
  if (!generationDir) {
6559
7180
  return fallback;
6560
7181
  }
6561
- const jobPath = path8.join(generationDir, "job.json");
7182
+ const jobPath = path10.join(generationDir, "job.json");
6562
7183
  try {
6563
- const raw = await readFile6(jobPath, "utf8");
7184
+ const raw = await readFile7(jobPath, "utf8");
6564
7185
  const parsed = JSON.parse(raw);
6565
7186
  const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
6566
7187
  const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
@@ -6576,9 +7197,9 @@ async function resolvePrimaryContentType(outputs) {
6576
7197
  // src/server/previewServer.ts
6577
7198
  import { execFile } from "child_process";
6578
7199
  import { promisify } from "util";
6579
- import { readFile as readFile7, stat as stat4 } from "fs/promises";
7200
+ import { readFile as readFile8, stat as stat5 } from "fs/promises";
6580
7201
  import { watch as fsWatch } from "fs";
6581
- import path9 from "path";
7202
+ import path11 from "path";
6582
7203
  import { fileURLToPath } from "url";
6583
7204
  import express from "express";
6584
7205
  import { marked } from "marked";
@@ -6731,7 +7352,7 @@ async function startPreviewServer(options) {
6731
7352
  if (options.watch) {
6732
7353
  let html2;
6733
7354
  try {
6734
- html2 = await readFile7(path9.join(previewClientDir, "index.html"), "utf8");
7355
+ html2 = await readFile8(path11.join(previewClientDir, "index.html"), "utf8");
6735
7356
  } catch {
6736
7357
  res.status(200).type("html").send(
6737
7358
  `<!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 +7363,7 @@ async function startPreviewServer(options) {
6742
7363
  const injected = html2.replace("</body>", `${reloadScript}</body>`);
6743
7364
  res.status(200).type("html").send(injected);
6744
7365
  } else {
6745
- res.status(200).sendFile(path9.join(previewClientDir, "index.html"));
7366
+ res.status(200).sendFile(path11.join(previewClientDir, "index.html"));
6746
7367
  }
6747
7368
  return;
6748
7369
  }
@@ -6795,7 +7416,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
6795
7416
  generation.outputs.map(async (output) => {
6796
7417
  let markdown = "";
6797
7418
  try {
6798
- markdown = await readFile7(output.sourcePath, "utf8");
7419
+ markdown = await readFile8(output.sourcePath, "utf8");
6799
7420
  } catch (error) {
6800
7421
  if (isMissingFileError(error)) {
6801
7422
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
@@ -6813,7 +7434,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
6813
7434
  };
6814
7435
  })
6815
7436
  );
6816
- const generationDir = path9.dirname(generation.outputs[0]?.sourcePath ?? "");
7437
+ const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
6817
7438
  const interactions = generationDir ? await loadSavedInteractions(generationDir) : { llmCalls: [], t2iCalls: [] };
6818
7439
  const analyticsSummary = generationDir ? await loadSavedAnalyticsSummary(generationDir) : null;
6819
7440
  return {
@@ -6842,7 +7463,7 @@ async function resolveActivePreviewArticle(preferredMarkdownPath, markdownOutput
6842
7463
  };
6843
7464
  }
6844
7465
  function resolveGenerationSourcePath(generation, markdownOutputDir) {
6845
- return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path9.join(markdownOutputDir, generation.id);
7466
+ return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path11.join(markdownOutputDir, generation.id);
6846
7467
  }
6847
7468
  function isMissingFileError(error) {
6848
7469
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -6857,7 +7478,7 @@ async function renderArticleHtml(markdown, generationId, sourcePath) {
6857
7478
  async function loadSavedLinks(markdownPath) {
6858
7479
  const linksPath = resolveLinksPath(markdownPath);
6859
7480
  try {
6860
- const raw = await readFile7(linksPath, "utf8");
7481
+ const raw = await readFile8(linksPath, "utf8");
6861
7482
  const parsed = JSON.parse(raw);
6862
7483
  if (!Array.isArray(parsed.links)) {
6863
7484
  return [];
@@ -6881,9 +7502,9 @@ async function loadSavedLinks(markdownPath) {
6881
7502
  }
6882
7503
  }
6883
7504
  async function loadSavedInteractions(generationDir) {
6884
- const interactionsPath = path9.join(generationDir, "model.interactions.json");
7505
+ const interactionsPath = path11.join(generationDir, "model.interactions.json");
6885
7506
  try {
6886
- const raw = await readFile7(interactionsPath, "utf8");
7507
+ const raw = await readFile8(interactionsPath, "utf8");
6887
7508
  const parsed = JSON.parse(raw);
6888
7509
  const llmCalls = Array.isArray(parsed.llmCalls) ? parsed.llmCalls.filter(isPreviewLlmInteraction) : [];
6889
7510
  const t2iCalls = Array.isArray(parsed.t2iCalls) ? parsed.t2iCalls.filter(isPreviewT2IInteraction) : [];
@@ -6899,9 +7520,9 @@ async function loadSavedInteractions(generationDir) {
6899
7520
  }
6900
7521
  }
6901
7522
  async function loadSavedAnalyticsSummary(generationDir) {
6902
- const analyticsPath = path9.join(generationDir, "generation.analytics.json");
7523
+ const analyticsPath = path11.join(generationDir, "generation.analytics.json");
6903
7524
  try {
6904
- const raw = await readFile7(analyticsPath, "utf8");
7525
+ const raw = await readFile8(analyticsPath, "utf8");
6905
7526
  const parsed = JSON.parse(raw);
6906
7527
  const summary = parsed.summary;
6907
7528
  if (!summary || typeof summary !== "object") {
@@ -6933,14 +7554,14 @@ async function getPreviewBootstrapData(preferredMarkdownPath, markdownOutputDir)
6933
7554
  };
6934
7555
  }
6935
7556
  async function resolvePreviewClientBuildDir() {
6936
- const currentDir = path9.dirname(fileURLToPath(import.meta.url));
7557
+ const currentDir = path11.dirname(fileURLToPath(import.meta.url));
6937
7558
  const candidates = [
6938
- path9.resolve(currentDir, "preview"),
6939
- path9.resolve(currentDir, "../../dist/preview")
7559
+ path11.resolve(currentDir, "preview"),
7560
+ path11.resolve(currentDir, "../../dist/preview")
6940
7561
  ];
6941
7562
  for (const candidate of candidates) {
6942
7563
  try {
6943
- const indexStat = await stat4(path9.join(candidate, "index.html"));
7564
+ const indexStat = await stat5(path11.join(candidate, "index.html"));
6944
7565
  if (indexStat.isFile()) {
6945
7566
  return candidate;
6946
7567
  }
@@ -7002,21 +7623,21 @@ async function resolveGenerationAssetPath(generationId, rawAssetPath, markdownOu
7002
7623
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
7003
7624
  }
7004
7625
  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)) {
7626
+ const normalizedRelative = path11.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7627
+ if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path11.posix.isAbsolute(normalizedRelative)) {
7007
7628
  throw new Error("Invalid generation asset path.");
7008
7629
  }
7009
- const generationDir = path9.dirname(generation.outputs[0]?.sourcePath ?? "");
7630
+ const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7010
7631
  if (!generationDir) {
7011
7632
  throw new MissingArticleError(`Generation "${generationId}" has no source directory.`);
7012
7633
  }
7013
- const resolvedPath = path9.resolve(generationDir, normalizedRelative);
7014
- const relativeToGeneration = path9.relative(generationDir, resolvedPath);
7015
- if (relativeToGeneration.startsWith("..") || path9.isAbsolute(relativeToGeneration)) {
7634
+ const resolvedPath = path11.resolve(generationDir, normalizedRelative);
7635
+ const relativeToGeneration = path11.relative(generationDir, resolvedPath);
7636
+ if (relativeToGeneration.startsWith("..") || path11.isAbsolute(relativeToGeneration)) {
7016
7637
  throw new Error("Invalid generation asset path.");
7017
7638
  }
7018
7639
  try {
7019
- const fileStat = await stat4(resolvedPath);
7640
+ const fileStat = await stat5(resolvedPath);
7020
7641
  if (!fileStat.isFile()) {
7021
7642
  throw new Error("Invalid generation asset path.");
7022
7643
  }
@@ -7088,10 +7709,6 @@ function renderShell({
7088
7709
  --newsletter-bg: #fffdf4;
7089
7710
  --newsletter-header-bg: #fff5cc;
7090
7711
  --newsletter-border: #cfb95a;
7091
- --landing-bg: linear-gradient(155deg, #10395c 0%, #3d7fa0 100%);
7092
- --landing-text: #f8fdff;
7093
- --landing-link: #d7f0ff;
7094
- --landing-border: rgba(255, 255, 255, 0.3);
7095
7712
  color-scheme: light;
7096
7713
  }
7097
7714
 
@@ -7131,10 +7748,6 @@ function renderShell({
7131
7748
  --newsletter-bg: #2e291b;
7132
7749
  --newsletter-header-bg: #3b331e;
7133
7750
  --newsletter-border: #d6b25f;
7134
- --landing-bg: linear-gradient(155deg, #0f2236 0%, #245d7e 100%);
7135
- --landing-text: #e7f4ff;
7136
- --landing-link: #b8e4ff;
7137
- --landing-border: rgba(220, 239, 255, 0.35);
7138
7751
  color-scheme: dark;
7139
7752
  }
7140
7753
  }
@@ -7174,10 +7787,6 @@ function renderShell({
7174
7787
  --newsletter-bg: #fffdf4;
7175
7788
  --newsletter-header-bg: #fff5cc;
7176
7789
  --newsletter-border: #cfb95a;
7177
- --landing-bg: linear-gradient(155deg, #10395c 0%, #3d7fa0 100%);
7178
- --landing-text: #f8fdff;
7179
- --landing-link: #d7f0ff;
7180
- --landing-border: rgba(255, 255, 255, 0.3);
7181
7790
  color-scheme: light;
7182
7791
  }
7183
7792
 
@@ -7216,10 +7825,6 @@ function renderShell({
7216
7825
  --newsletter-bg: #2e291b;
7217
7826
  --newsletter-header-bg: #3b331e;
7218
7827
  --newsletter-border: #d6b25f;
7219
- --landing-bg: linear-gradient(155deg, #0f2236 0%, #245d7e 100%);
7220
- --landing-text: #e7f4ff;
7221
- --landing-link: #b8e4ff;
7222
- --landing-border: rgba(220, 239, 255, 0.35);
7223
7828
  color-scheme: dark;
7224
7829
  }
7225
7830
 
@@ -7739,21 +8344,6 @@ function renderShell({
7739
8344
  background: var(--newsletter-header-bg);
7740
8345
  }
7741
8346
 
7742
- .channel-landing-page-copy {
7743
- background: var(--landing-bg);
7744
- color: var(--landing-text);
7745
- border: none;
7746
- }
7747
-
7748
- .channel-landing-page-copy .channel-header {
7749
- border-bottom: 1px solid var(--landing-border);
7750
- }
7751
-
7752
- .channel-landing-page-copy .channel-meta,
7753
- .channel-landing-page-copy a {
7754
- color: var(--landing-link);
7755
- }
7756
-
7757
8347
  .channel-article,
7758
8348
  .channel-blog-post {
7759
8349
  background: var(--paper);
@@ -7939,7 +8529,7 @@ function renderShell({
7939
8529
  const articleElement = document.getElementById('article');
7940
8530
  const articleListElement = document.getElementById('articleList');
7941
8531
  const themeToggleButton = document.getElementById('themeToggle');
7942
- const typeOrder = ['article', 'blog-post', 'x-thread', 'x-post', 'linkedin-post', 'reddit-post', 'newsletter', 'landing-page-copy'];
8532
+ const typeOrder = ['article', 'blog-post', 'x-thread', 'x-post', 'linkedin-post', 'reddit-post', 'newsletter'];
7943
8533
  const stageOrder = ['shared-brief', 'planning', 'sections', 'image-prompts', 'images', 'output', 'links'];
7944
8534
 
7945
8535
  let currentGeneration = null;
@@ -8527,7 +9117,7 @@ async function runServeCommand(options) {
8527
9117
  const markdownPath = await resolveMarkdownPath(options.markdownPath, outputPaths.markdownOutputDir, process.cwd());
8528
9118
  const port = parsePort(options.port);
8529
9119
  if (options.watch) {
8530
- const viteBin = path10.resolve(process.cwd(), "node_modules", ".bin", "vite");
9120
+ const viteBin = path12.resolve(process.cwd(), "node_modules", ".bin", "vite");
8531
9121
  const viteProcess = spawn(viteBin, ["build", "--watch"], {
8532
9122
  stdio: "inherit",
8533
9123
  shell: process.platform === "win32"
@@ -8553,8 +9143,8 @@ async function runServeCommand(options) {
8553
9143
  openBrowser: options.openBrowser,
8554
9144
  watch: options.watch
8555
9145
  });
8556
- const relativeArticle = path10.relative(process.cwd(), markdownPath);
8557
- const relativeAssets = path10.relative(process.cwd(), outputPaths.assetOutputDir);
9146
+ const relativeArticle = path12.relative(process.cwd(), markdownPath);
9147
+ const relativeAssets = path12.relative(process.cwd(), outputPaths.assetOutputDir);
8558
9148
  console.log(`Previewing ${relativeArticle || markdownPath}`);
8559
9149
  console.log(`Serving assets from ${relativeAssets || outputPaths.assetOutputDir}`);
8560
9150
  console.log(`Open ${server.url}`);
@@ -8594,11 +9184,28 @@ function colon(id) {
8594
9184
  function value(id, text) {
8595
9185
  return { id, text, color: "white" };
8596
9186
  }
9187
+ function formatStageCost(costUsd, costSource) {
9188
+ const formatted = formatCost(costUsd);
9189
+ if (costUsd === null) {
9190
+ return formatted;
9191
+ }
9192
+ return costSource === "estimated" ? `~${formatted}` : formatted;
9193
+ }
9194
+ function formatStageId(stageId) {
9195
+ if (stageId === "shared-brief") return "shared-brief";
9196
+ if (stageId === "planning") return "planning";
9197
+ if (stageId === "sections") return "sections";
9198
+ if (stageId === "image-prompts") return "image-prompts";
9199
+ if (stageId === "images") return "images";
9200
+ if (stageId === "output") return "output";
9201
+ if (stageId === "links") return "links";
9202
+ return stageId;
9203
+ }
8597
9204
  function buildFinalSummaryRows({
8598
9205
  artifact,
8599
9206
  analytics
8600
9207
  }) {
8601
- return [
9208
+ const rows = [
8602
9209
  {
8603
9210
  id: "slug",
8604
9211
  segments: [
@@ -8648,6 +9255,16 @@ function buildFinalSummaryRows({
8648
9255
  ]
8649
9256
  }
8650
9257
  ];
9258
+ const stageCostRows = analytics.stages.map((stage) => ({
9259
+ id: `stage-cost:${stage.stageId}`,
9260
+ segments: [
9261
+ label(`stage-cost-label:${stage.stageId}`, `cost/${formatStageId(stage.stageId)}`, "greenBright"),
9262
+ colon(`stage-cost-colon:${stage.stageId}`),
9263
+ value(`stage-cost-value:${stage.stageId}`, formatStageCost(stage.costUsd, stage.costSource))
9264
+ ]
9265
+ }));
9266
+ rows.push(...stageCostRows);
9267
+ return rows;
8651
9268
  }
8652
9269
 
8653
9270
  // src/cli/ui/finalSummary.tsx
@@ -8715,7 +9332,7 @@ function formatDuration(durationMs) {
8715
9332
  }
8716
9333
  return `${durationMs}ms`;
8717
9334
  }
8718
- function formatStageCost(stage) {
9335
+ function formatStageCost2(stage) {
8719
9336
  const analytics = stage.stageAnalytics;
8720
9337
  if (!analytics || analytics.costUsd === null) {
8721
9338
  return "no cost data";
@@ -8723,6 +9340,15 @@ function formatStageCost(stage) {
8723
9340
  const formatted = `$${analytics.costUsd.toFixed(4)}`;
8724
9341
  return analytics.costSource === "estimated" ? `~${formatted}` : formatted;
8725
9342
  }
9343
+ function formatRetryContext(stage) {
9344
+ if (!stage.retryCount || stage.retryCount <= 0) {
9345
+ return "";
9346
+ }
9347
+ if (stage.lastRetryError) {
9348
+ return ` \u2022 retried ${stage.retryCount}x \u2022 last error: ${stage.lastRetryError}`;
9349
+ }
9350
+ return ` \u2022 retried ${stage.retryCount}x`;
9351
+ }
8726
9352
  function StageRow({
8727
9353
  stage,
8728
9354
  isActive,
@@ -8748,7 +9374,10 @@ function StageRow({
8748
9374
  /* @__PURE__ */ jsx4(Text3, { children: " " }),
8749
9375
  /* @__PURE__ */ jsx4(Text3, { bold: stage.status === "running", children: stage.title })
8750
9376
  ] }),
8751
- /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsx4(Text3, { color: "gray", children: stage.detail }) }),
9377
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
9378
+ stage.detail,
9379
+ formatRetryContext(stage)
9380
+ ] }) }),
8752
9381
  /* @__PURE__ */ jsx4(
8753
9382
  ItemRows,
8754
9383
  {
@@ -8763,7 +9392,7 @@ function StageRow({
8763
9392
  "analytics: ",
8764
9393
  formatDuration(stage.stageAnalytics.durationMs),
8765
9394
  " \u2022 cost: ",
8766
- formatStageCost(stage)
9395
+ formatStageCost2(stage)
8767
9396
  ] }) }) : null
8768
9397
  ] });
8769
9398
  }
@@ -8947,7 +9576,7 @@ function formatDuration2(durationMs) {
8947
9576
  }
8948
9577
  return `${durationMs}ms`;
8949
9578
  }
8950
- function formatStageCost2(stage) {
9579
+ function formatStageCost3(stage) {
8951
9580
  const analytics = stage.stageAnalytics;
8952
9581
  if (!analytics) {
8953
9582
  return "unavailable";
@@ -8959,9 +9588,19 @@ function formatStage(stage) {
8959
9588
  const summary = stage.summary ? `
8960
9589
  ${stage.summary}` : "";
8961
9590
  const analytics = stage.status === "succeeded" && stage.stageAnalytics ? `
8962
- analytics: ${formatDuration2(stage.stageAnalytics.durationMs)} \u2022 cost: ${formatStageCost2(stage)}` : "";
9591
+ analytics: ${formatDuration2(stage.stageAnalytics.durationMs)} \u2022 cost: ${formatStageCost3(stage)}` : "";
9592
+ const retryContext = formatRetryContext2(stage);
8963
9593
  return `[${stage.status}] ${stage.title}
8964
- ${stage.detail}${summary}${analytics}`;
9594
+ ${stage.detail}${retryContext}${summary}${analytics}`;
9595
+ }
9596
+ function formatRetryContext2(stage) {
9597
+ if (!stage.retryCount || stage.retryCount <= 0) {
9598
+ return "";
9599
+ }
9600
+ if (stage.lastRetryError) {
9601
+ return ` \u2022 retried ${stage.retryCount}x \u2022 last error: ${stage.lastRetryError}`;
9602
+ }
9603
+ return ` \u2022 retried ${stage.retryCount}x`;
8965
9604
  }
8966
9605
  function formatItem(stage, item) {
8967
9606
  const summary = item.summary ? `
@@ -8984,8 +9623,15 @@ function formatCost2(costUsd) {
8984
9623
  }
8985
9624
  return `$${costUsd.toFixed(4)}`;
8986
9625
  }
8987
- async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
8988
- let previousStatuses = /* @__PURE__ */ new Map();
9626
+ function formatPipelineStageCost(stage) {
9627
+ const formatted = formatCost2(stage.costUsd);
9628
+ if (stage.costUsd === null) {
9629
+ return formatted;
9630
+ }
9631
+ return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9632
+ }
9633
+ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks) {
9634
+ let previousStages = /* @__PURE__ */ new Map();
8989
9635
  let previousItemStatuses = /* @__PURE__ */ new Map();
8990
9636
  const notificationsEnabled = input.config.settings.notifications.enabled;
8991
9637
  try {
@@ -8998,12 +9644,21 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
8998
9644
  dryRun,
8999
9645
  enrichLinks: enrichLinks2,
9000
9646
  runMode,
9647
+ customLinks: links,
9648
+ unlinks,
9649
+ maxLinks,
9001
9650
  onUpdate(stages) {
9002
9651
  for (const stage of stages) {
9003
- const previous = previousStatuses.get(stage.id);
9004
- if (previous !== stage.status) {
9652
+ const previous = previousStages.get(stage.id);
9653
+ const shouldLogStage = !previous || previous.status !== stage.status || stage.status === "running" && (previous.detail !== stage.detail || previous.retryCount !== stage.retryCount || previous.lastRetryError !== stage.lastRetryError);
9654
+ if (shouldLogStage) {
9005
9655
  console.log(formatStage(stage));
9006
- previousStatuses.set(stage.id, stage.status);
9656
+ previousStages.set(stage.id, {
9657
+ status: stage.status,
9658
+ detail: stage.detail,
9659
+ retryCount: stage.retryCount,
9660
+ lastRetryError: stage.lastRetryError
9661
+ });
9007
9662
  }
9008
9663
  for (const item of stage.items ?? []) {
9009
9664
  const itemKey = `${stage.id}:${item.id}`;
@@ -9029,6 +9684,10 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode) {
9029
9684
  console.log(` duration_ms: ${result.analytics.summary.totalDurationMs}`);
9030
9685
  console.log(` retries: ${result.analytics.summary.totalRetries}`);
9031
9686
  console.log(` cost: ${formatCost2(result.analytics.summary.totalCostUsd)}`);
9687
+ console.log(" cost_by_stage:");
9688
+ for (const stage of result.analytics.stages) {
9689
+ console.log(` ${stage.stageId}: ${formatPipelineStageCost(stage)}`);
9690
+ }
9032
9691
  await notifyWriteSucceeded({
9033
9692
  enabled: notificationsEnabled,
9034
9693
  title: result.artifact.title,
@@ -9053,9 +9712,11 @@ import TextInput2 from "ink-text-input";
9053
9712
  import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
9054
9713
  function WriteOptionsFlow({
9055
9714
  askStyle,
9715
+ askIntent,
9056
9716
  askTargets,
9057
9717
  askLength,
9058
9718
  initialStyle,
9719
+ initialIntent,
9059
9720
  initialTargetLength,
9060
9721
  initialTargets,
9061
9722
  onDone
@@ -9064,6 +9725,7 @@ function WriteOptionsFlow({
9064
9725
  const [step, setStep] = useState3(() => {
9065
9726
  if (askTargets) return "primary";
9066
9727
  if (askStyle) return "style";
9728
+ if (askIntent) return "intent";
9067
9729
  if (askLength) return "length";
9068
9730
  return "primary";
9069
9731
  });
@@ -9089,6 +9751,7 @@ function WriteOptionsFlow({
9089
9751
  const [countInput, setCountInput] = useState3("1");
9090
9752
  const [countIndex, setCountIndex] = useState3(0);
9091
9753
  const [style, setStyle] = useState3(initialStyle);
9754
+ const [intent, setIntent] = useState3(initialIntent);
9092
9755
  const [targetLength, setTargetLength] = useState3(initialTargetLength);
9093
9756
  const selectedSecondaryTypes = useMemo2(
9094
9757
  () => secondarySelections.filter((item) => item.checked).map((item) => item.contentType),
@@ -9215,6 +9878,8 @@ function WriteOptionsFlow({
9215
9878
  if (nextIndex >= countTypes.length) {
9216
9879
  if (askStyle) {
9217
9880
  setStep("style");
9881
+ } else if (askIntent) {
9882
+ setStep("intent");
9218
9883
  } else if (askLength) {
9219
9884
  setStep("length");
9220
9885
  } else {
@@ -9252,6 +9917,10 @@ function WriteOptionsFlow({
9252
9917
  onSelect: (item) => {
9253
9918
  const contentTargets = askTargets ? buildContentTargets(primaryType, selectedSecondaryTypes) : void 0;
9254
9919
  setStyle(item.value);
9920
+ if (askIntent) {
9921
+ setStep("intent");
9922
+ return;
9923
+ }
9255
9924
  if (askLength) {
9256
9925
  setStep("length");
9257
9926
  return;
@@ -9266,6 +9935,37 @@ function WriteOptionsFlow({
9266
9935
  ) })
9267
9936
  ] });
9268
9937
  }
9938
+ const intentItems = contentIntentValues.map((value2) => ({
9939
+ label: value2,
9940
+ value: value2
9941
+ }));
9942
+ if (step === "intent") {
9943
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
9944
+ /* @__PURE__ */ jsx6(Text5, { bold: true, color: "cyanBright", children: "Select Intent" }),
9945
+ /* @__PURE__ */ jsx6(Text5, { color: "gray", children: "Choose the primary content intent for this generation run." }),
9946
+ /* @__PURE__ */ jsx6(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx6(
9947
+ SelectInput2,
9948
+ {
9949
+ items: intentItems,
9950
+ initialIndex: Math.max(0, intentItems.findIndex((item) => item.value === intent)),
9951
+ onSelect: (item) => {
9952
+ const contentTargets = askTargets ? buildContentTargets(primaryType, selectedSecondaryTypes) : void 0;
9953
+ setIntent(item.value);
9954
+ if (askLength) {
9955
+ setStep("length");
9956
+ return;
9957
+ }
9958
+ onDone({
9959
+ ...askStyle ? { style } : {},
9960
+ intent: item.value,
9961
+ ...contentTargets ? { contentTargets } : {}
9962
+ });
9963
+ exit();
9964
+ }
9965
+ }
9966
+ ) })
9967
+ ] });
9968
+ }
9269
9969
  const lengthItems = targetLengthValues.map((value2) => ({
9270
9970
  label: value2,
9271
9971
  value: value2
@@ -9284,6 +9984,7 @@ function WriteOptionsFlow({
9284
9984
  setTargetLength(item.value);
9285
9985
  onDone({
9286
9986
  ...askStyle ? { style } : {},
9987
+ ...askIntent ? { intent } : {},
9287
9988
  targetLength: item.value,
9288
9989
  ...contentTargets ? { contentTargets } : {}
9289
9990
  });
@@ -9304,6 +10005,9 @@ function WriteApp({
9304
10005
  dryRun,
9305
10006
  enrichLinks: enrichLinks2,
9306
10007
  runMode,
10008
+ links,
10009
+ unlinks,
10010
+ maxLinks,
9307
10011
  onError
9308
10012
  }) {
9309
10013
  const { exit } = useApp3();
@@ -9327,6 +10031,9 @@ function WriteApp({
9327
10031
  dryRun,
9328
10032
  enrichLinks: enrichLinks2,
9329
10033
  runMode,
10034
+ customLinks: links,
10035
+ unlinks,
10036
+ maxLinks,
9330
10037
  onUpdate(nextStages) {
9331
10038
  if (mounted) {
9332
10039
  setStages(nextStages);
@@ -9359,7 +10066,7 @@ function WriteApp({
9359
10066
  return () => {
9360
10067
  mounted = false;
9361
10068
  };
9362
- }, [dryRun, enrichLinks2, input, onError, runMode]);
10069
+ }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, onError, runMode]);
9363
10070
  useEffect2(() => {
9364
10071
  if (!result && !errorMessage) {
9365
10072
  return;
@@ -9375,7 +10082,7 @@ function WriteApp({
9375
10082
  }
9376
10083
  async function runWriteCommand(options) {
9377
10084
  const input = await resolveInputWithInteractiveIdeaFallback(options);
9378
- await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive);
10085
+ await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks);
9379
10086
  }
9380
10087
  async function runWriteResumeCommand(options = {}) {
9381
10088
  const session = await loadWriteSession();
@@ -9397,9 +10104,9 @@ async function runWriteResumeCommand(options = {}) {
9397
10104
  secrets: resolved.config.secrets
9398
10105
  }
9399
10106
  };
9400
- await runWritePipeline(input, session.dryRun, true, "resume", options.noInteractive ?? false);
10107
+ await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks);
9401
10108
  }
9402
- async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive) {
10109
+ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks) {
9403
10110
  let interruptHandled = false;
9404
10111
  const handleSignal = (signal) => {
9405
10112
  if (interruptHandled) {
@@ -9433,7 +10140,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
9433
10140
  process.on("SIGTERM", onSigterm);
9434
10141
  try {
9435
10142
  if (noInteractive || !process.stdout.isTTY) {
9436
- await renderPlainPipeline(input, dryRun, enrichLinks2, runMode);
10143
+ await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks);
9437
10144
  return;
9438
10145
  }
9439
10146
  let commandError = null;
@@ -9445,6 +10152,9 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
9445
10152
  dryRun,
9446
10153
  enrichLinks: enrichLinks2,
9447
10154
  runMode,
10155
+ links,
10156
+ unlinks,
10157
+ maxLinks,
9448
10158
  onError: (error) => {
9449
10159
  commandError = error;
9450
10160
  }
@@ -9482,6 +10192,7 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9482
10192
  audience: options.audience,
9483
10193
  jobPath: options.jobPath,
9484
10194
  style: options.style,
10195
+ intent: options.intent,
9485
10196
  targetLength: options.length,
9486
10197
  contentTargets: parsedTargets
9487
10198
  });
@@ -9499,6 +10210,7 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9499
10210
  audience: options.audience,
9500
10211
  jobPath: options.jobPath,
9501
10212
  style: options.style,
10213
+ intent: options.intent,
9502
10214
  targetLength: options.length,
9503
10215
  contentTargets: parsedTargets
9504
10216
  });
@@ -9507,12 +10219,14 @@ async function resolveInputWithInteractiveIdeaFallback(options) {
9507
10219
  }
9508
10220
  async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTargets) {
9509
10221
  const styleProvided = Boolean(options.style ?? resolved.job?.settings?.style);
10222
+ const intentProvided = Boolean(options.intent);
9510
10223
  const lengthProvided = Boolean(options.length ?? resolved.job?.settings?.targetLength);
9511
10224
  const providedTargets = parsedTargets && parsedTargets.length > 0 ? parsedTargets : resolved.job?.settings?.contentTargets ?? resolved.config.settings.contentTargets;
9512
10225
  const targetsProvided = Boolean(parsedTargets && parsedTargets.length > 0 || resolved.job?.settings?.contentTargets?.length);
9513
- if (options.noInteractive && (!styleProvided || !targetsProvided || !lengthProvided)) {
10226
+ if (options.noInteractive && (!styleProvided || !intentProvided || !targetsProvided || !lengthProvided)) {
9514
10227
  const missingFlags = [
9515
10228
  !styleProvided ? "--style <style>" : null,
10229
+ !intentProvided ? "--intent <intent>" : null,
9516
10230
  !targetsProvided ? "--primary <content-type=1>" : null,
9517
10231
  !lengthProvided ? "--length <size>" : null
9518
10232
  ].filter((value2) => Boolean(value2));
@@ -9523,15 +10237,17 @@ async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTar
9523
10237
  if (!process.stdout.isTTY || !process.stdin.isTTY || options.noInteractive) {
9524
10238
  return resolved;
9525
10239
  }
9526
- if (styleProvided && targetsProvided && lengthProvided) {
10240
+ if (styleProvided && intentProvided && targetsProvided && lengthProvided) {
9527
10241
  return resolved;
9528
10242
  }
9529
10243
  const prompted = await promptForMissingWriteOptions({
9530
10244
  askStyle: !styleProvided,
10245
+ askIntent: !intentProvided,
9531
10246
  askTargets: !targetsProvided,
9532
10247
  askLength: !lengthProvided,
9533
10248
  style: resolved.config.settings.style,
9534
10249
  targetLength: resolved.config.settings.targetLength,
10250
+ intent: resolved.config.settings.intent,
9535
10251
  targets: providedTargets
9536
10252
  });
9537
10253
  return {
@@ -9541,6 +10257,7 @@ async function applyInteractiveWriteOptionsIfNeeded(resolved, options, parsedTar
9541
10257
  settings: appSettingsSchema.parse({
9542
10258
  ...resolved.config.settings,
9543
10259
  ...prompted.style ? { style: prompted.style } : {},
10260
+ ...prompted.intent ? { intent: prompted.intent } : {},
9544
10261
  ...prompted.targetLength ? { targetLength: prompted.targetLength } : {},
9545
10262
  ...prompted.contentTargets ? { contentTargets: prompted.contentTargets } : {}
9546
10263
  })
@@ -9552,10 +10269,12 @@ async function promptForMissingWriteOptions(params) {
9552
10269
  const app = render2(
9553
10270
  React4.createElement(WriteOptionsFlow, {
9554
10271
  askStyle: params.askStyle,
10272
+ askIntent: params.askIntent,
9555
10273
  askTargets: params.askTargets,
9556
10274
  askLength: params.askLength,
9557
10275
  initialStyle: writingStyleValues.includes(params.style) ? params.style : "professional",
9558
- initialTargetLength: targetLengthValues.includes(params.targetLength) ? params.targetLength : "medium",
10276
+ initialIntent: contentIntentValues.includes(params.intent) ? params.intent : "tutorial",
10277
+ initialTargetLength: resolveTargetLengthAlias(params.targetLength),
9559
10278
  initialTargets: params.targets,
9560
10279
  onDone: (result) => {
9561
10280
  flowResult = result;
@@ -9639,6 +10358,15 @@ async function runCli(argv) {
9639
10358
  force: options.force
9640
10359
  });
9641
10360
  });
10361
+ 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").option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (slug, options) => {
10362
+ await runLinksCommand({
10363
+ slug,
10364
+ mode: options.mode,
10365
+ links: options.link,
10366
+ unlinks: options.unlink,
10367
+ maxLinks: options.maxLinks
10368
+ });
10369
+ });
9642
10370
  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
10371
  await runServeCommand({
9644
10372
  markdownPath,
@@ -9647,7 +10375,7 @@ async function runCli(argv) {
9647
10375
  watch: options.watch
9648
10376
  });
9649
10377
  });
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) => {
10378
+ 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).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (ideaArg, options) => {
9651
10379
  await runWriteCommand({
9652
10380
  idea: options.idea ?? ideaArg,
9653
10381
  audience: options.audience,
@@ -9655,14 +10383,24 @@ async function runCli(argv) {
9655
10383
  primarySpec: options.primary,
9656
10384
  secondarySpecs: options.secondary,
9657
10385
  style: options.style,
10386
+ intent: options.intent,
9658
10387
  length: options.length,
9659
10388
  noInteractive: !options.interactive,
9660
10389
  dryRun: options.dryRun,
9661
- enrichLinks: options.enrichLinks
10390
+ enrichLinks: options.enrichLinks,
10391
+ links: options.link,
10392
+ unlinks: options.unlink,
10393
+ maxLinks: options.maxLinks
9662
10394
  });
9663
10395
  });
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 });
10396
+ 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).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (options) => {
10397
+ await runWriteResumeCommand({
10398
+ noInteractive: options.noInteractive,
10399
+ enrichLinks: options.enrichLinks,
10400
+ links: options.link,
10401
+ unlinks: options.unlink,
10402
+ maxLinks: options.maxLinks
10403
+ });
9666
10404
  });
9667
10405
  await program.parseAsync(argv);
9668
10406
  }