@telepat/ideon 0.1.15 → 0.1.18

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
@@ -118,7 +118,7 @@ var notificationsSettingsSchema = z.object({
118
118
  enabled: z.boolean().default(false)
119
119
  });
120
120
  var appSettingsSchema = z.object({
121
- model: z.string().default("moonshotai/kimi-k2.5"),
121
+ model: z.string().default("deepseek/deepseek-v4-pro"),
122
122
  modelSettings: modelSettingsSchema.default(modelSettingsSchema.parse({})),
123
123
  modelRequestTimeoutMs: z.number().int().positive().default(9e4),
124
124
  t2i: baseT2ISettingsSchema.default(baseT2ISettingsSchema.parse({})),
@@ -521,7 +521,7 @@ import { mkdir as mkdir3, readFile as readFile2, writeFile as writeFile3 } from
521
521
  import path4 from "path";
522
522
  import envPaths2 from "env-paths";
523
523
  import { z as z2 } from "zod";
524
- var supportedAgentRuntimeValues = ["claude", "chatgpt", "gemini", "generic-mcp"];
524
+ var supportedAgentRuntimeValues = ["claude", "claude-desktop", "chatgpt", "gemini", "codex", "cursor", "vscode", "opencode", "generic-mcp"];
525
525
  var integrationEntrySchema = z2.object({
526
526
  runtime: z2.enum(supportedAgentRuntimeValues),
527
527
  installedAt: z2.string(),
@@ -975,7 +975,8 @@ var writeToolInputSchema = {
975
975
  enrichLinks: z3.boolean().optional(),
976
976
  link: z3.array(z3.string()).optional(),
977
977
  unlink: z3.array(z3.string()).optional(),
978
- maxLinks: z3.coerce.number().int().positive().optional()
978
+ maxLinks: z3.coerce.number().int().positive().optional(),
979
+ maxImages: z3.coerce.number().int().min(1).optional()
979
980
  };
980
981
  var writeToolInputZodSchema = z3.object(writeToolInputSchema);
981
982
  var writeResumeToolInputSchema = {
@@ -983,7 +984,8 @@ var writeResumeToolInputSchema = {
983
984
  enrichLinks: z3.boolean().optional(),
984
985
  link: z3.array(z3.string()).optional(),
985
986
  unlink: z3.array(z3.string()).optional(),
986
- maxLinks: z3.coerce.number().int().positive().optional()
987
+ maxLinks: z3.coerce.number().int().positive().optional(),
988
+ maxImages: z3.coerce.number().int().min(1).optional()
987
989
  };
988
990
  var writeResumeToolInputZodSchema = z3.object(writeResumeToolInputSchema);
989
991
  var deleteToolInputSchema = {
@@ -1007,6 +1009,13 @@ var linksToolInputSchema = {
1007
1009
  maxLinks: z3.coerce.number().int().positive().optional()
1008
1010
  };
1009
1011
  var linksToolInputZodSchema = z3.object(linksToolInputSchema);
1012
+ var exportToolInputSchema = {
1013
+ generationId: z3.string().min(1),
1014
+ destinationPath: z3.string().min(1),
1015
+ index: z3.coerce.number().int().positive().optional(),
1016
+ overwrite: z3.boolean().optional()
1017
+ };
1018
+ var exportToolInputZodSchema = z3.object(exportToolInputSchema);
1010
1019
  var configListToolInputSchema = {};
1011
1020
  var configListToolInputZodSchema = z3.object(configListToolInputSchema);
1012
1021
  var configUnsetToolInputSchema = {
@@ -1040,6 +1049,11 @@ var ideonToolContracts = [
1040
1049
  mode: ["fresh", "append"]
1041
1050
  }
1042
1051
  },
1052
+ {
1053
+ name: "ideon_export",
1054
+ required: ["generationId", "destinationPath"],
1055
+ enums: {}
1056
+ },
1043
1057
  {
1044
1058
  name: "ideon_config_set",
1045
1059
  required: ["key", "value"],
@@ -1208,7 +1222,6 @@ function compareStringArrays(drifts, id, actual, expected) {
1208
1222
  }
1209
1223
 
1210
1224
  // src/cli/commands/agent.ts
1211
- var unsupportedIdeRuntimeAliases = ["cursor", "vscode"];
1212
1225
  var defaultDependencies = {
1213
1226
  install: installAgentIntegration,
1214
1227
  uninstall: uninstallAgentIntegration,
@@ -1278,11 +1291,6 @@ async function collectAgentStatus(deps) {
1278
1291
  }
1279
1292
  function parseRuntime(rawRuntime) {
1280
1293
  const runtime = rawRuntime.trim().toLowerCase();
1281
- if (unsupportedIdeRuntimeAliases.includes(runtime)) {
1282
- throw new ReportedError(
1283
- `Unsupported runtime "${rawRuntime}". Ideon agent integration does not support Cursor or VS Code runtimes.`
1284
- );
1285
- }
1286
1294
  if (!supportedAgentRuntimeValues.includes(runtime)) {
1287
1295
  throw new ReportedError(
1288
1296
  `Unsupported runtime "${rawRuntime}". Supported runtimes: ${supportedAgentRuntimeValues.join(", ")}.`
@@ -1354,7 +1362,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1354
1362
  // package.json
1355
1363
  var package_default = {
1356
1364
  name: "@telepat/ideon",
1357
- version: "0.1.15",
1365
+ version: "0.1.18",
1358
1366
  description: "CLI for generating rich articles and images from ideas.",
1359
1367
  type: "module",
1360
1368
  repository: {
@@ -2275,9 +2283,8 @@ function buildArticlePlanJsonSchema(targetLengthWords) {
2275
2283
  items: {
2276
2284
  type: "object",
2277
2285
  additionalProperties: false,
2278
- required: ["anchorAfterSection", "description"],
2286
+ required: ["description"],
2279
2287
  properties: {
2280
- anchorAfterSection: { type: "integer", minimum: 1, maximum: 10 },
2281
2288
  description: { type: "string" }
2282
2289
  }
2283
2290
  }
@@ -2316,7 +2323,7 @@ function buildArticlePlanMessages(idea, options) {
2316
2323
  "- Sections are article-only structure and must not be treated as requirements for non-article channels.",
2317
2324
  "- Include a cover image description and 2 to 3 inline image descriptions.",
2318
2325
  "- Image descriptions must be concrete and contextual, not generic stock-photo phrasing.",
2319
- "- Inline images should be anchored after specific sections using 1-based indexes.",
2326
+ "- Do not include section placement for inline images \u2014 placement is determined automatically after writing.",
2320
2327
  "",
2321
2328
  "Shared content brief context:",
2322
2329
  `- description: ${options.contentBrief.description}`,
@@ -2335,7 +2342,7 @@ function buildArticlePlanMessages(idea, options) {
2335
2342
  "- outroBrief: string",
2336
2343
  `- sections: array of ${sectionCounts.label} objects, each with title and description strings`,
2337
2344
  "- coverImageDescription: string",
2338
- "- inlineImages: array of 2 to 3 objects, each with anchorAfterSection (integer 1 to 10) and description string",
2345
+ "- inlineImages: array of 2 to 3 objects, each with a description string only",
2339
2346
  "",
2340
2347
  "Do not omit any required fields. Return strict JSON only."
2341
2348
  ].join("\n")
@@ -2350,7 +2357,6 @@ var articleSectionPlanSchema = z5.object({
2350
2357
  description: z5.string().min(1)
2351
2358
  });
2352
2359
  var inlineImagePlanSchema = z5.object({
2353
- anchorAfterSection: z5.number().int().min(1).max(10),
2354
2360
  description: z5.string().min(1)
2355
2361
  });
2356
2362
  var articlePlanSchema = z5.object({
@@ -2406,7 +2412,7 @@ async function planArticle({
2406
2412
  ...basePlan,
2407
2413
  slug: uniqueSlug,
2408
2414
  keywords: basePlan.keywords.slice(0, 8),
2409
- inlineImages: basePlan.inlineImages.filter((image) => image.anchorAfterSection <= basePlan.sections.length).slice(0, 3)
2415
+ inlineImages: basePlan.inlineImages.slice(0, 3)
2410
2416
  };
2411
2417
  }
2412
2418
  function buildDryRunPlan(idea, contentBrief) {
@@ -2444,11 +2450,9 @@ function buildDryRunPlan(idea, contentBrief) {
2444
2450
  coverImageDescription: "A refined editorial workspace with notebooks, sketches, and glowing structured outlines, cinematic but minimal.",
2445
2451
  inlineImages: [
2446
2452
  {
2447
- anchorAfterSection: 1,
2448
2453
  description: "A rough idea evolving into a structured article outline on a desk full of notes."
2449
2454
  },
2450
2455
  {
2451
- anchorAfterSection: 3,
2452
2456
  description: "A collaborative editorial scene where human judgment and AI suggestions coexist."
2453
2457
  }
2454
2458
  ]
@@ -2943,7 +2947,21 @@ var imagePromptSchema = {
2943
2947
  prompt: { type: "string" }
2944
2948
  }
2945
2949
  };
2946
- function buildImagePromptMessages(plan, image) {
2950
+ function buildImagePromptMessages(plan, image, section) {
2951
+ const userLines = [
2952
+ `Article title: ${plan.title}`,
2953
+ `Article subtitle: ${plan.subtitle}`,
2954
+ `Article description: ${plan.description}`,
2955
+ `Image kind: ${image.kind}`,
2956
+ `Image description: ${image.description}`
2957
+ ];
2958
+ if (section) {
2959
+ userLines.push(
2960
+ `Section title: ${section.title}`,
2961
+ `Section excerpt: ${section.body.slice(0, 500)}`
2962
+ );
2963
+ }
2964
+ userLines.push("Write one strong prompt for a clean editorial illustration. Avoid text overlays or watermarks.");
2947
2965
  return [
2948
2966
  {
2949
2967
  role: "system",
@@ -2951,14 +2969,7 @@ function buildImagePromptMessages(plan, image) {
2951
2969
  },
2952
2970
  {
2953
2971
  role: "user",
2954
- content: [
2955
- `Article title: ${plan.title}`,
2956
- `Article subtitle: ${plan.subtitle}`,
2957
- `Article description: ${plan.description}`,
2958
- `Image kind: ${image.kind}`,
2959
- `Image description: ${image.description}`,
2960
- "Write one strong prompt for a clean editorial illustration. Avoid text overlays or watermarks."
2961
- ].join("\n")
2972
+ content: userLines.join("\n")
2962
2973
  }
2963
2974
  ];
2964
2975
  }
@@ -3428,11 +3439,12 @@ function clampNumber(value2, minimum, maximum, integer, nullable) {
3428
3439
  // src/pipeline/analytics.ts
3429
3440
  var LLM_USD_PER_1K_TOKENS = {
3430
3441
  // AUTO-GENERATED:OPENROUTER_PRICING_START
3431
- // Last refreshed: 2026-03-27
3442
+ // Last refreshed: 2026-05-05
3432
3443
  // Source: https://openrouter.ai/api/v1/models (per-token USD converted to per-1k-token USD)
3433
3444
  "anthropic/claude-3.5-sonnet": { input: 6e-3, output: 0.03 },
3434
3445
  "deepseek/deepseek-chat": { input: 32e-5, output: 89e-5 },
3435
- "moonshotai/kimi-k2.5": { input: 45e-5, output: 22e-4 },
3446
+ "deepseek/deepseek-v4-pro": { input: 435e-6, output: 87e-5 },
3447
+ "moonshotai/kimi-k2.5": { input: 44e-5, output: 2e-3 },
3436
3448
  "openai/gpt-4o-mini": { input: 15e-5, output: 6e-4 }
3437
3449
  // AUTO-GENERATED:OPENROUTER_PRICING_END
3438
3450
  };
@@ -3520,8 +3532,52 @@ function sumKnownCosts(values) {
3520
3532
 
3521
3533
  // src/images/renderImages.ts
3522
3534
  var MIN_IMAGE_BYTES = 1024;
3535
+ function selectImageSlots(plan, sections, options) {
3536
+ const sectionCount = sections.length;
3537
+ let defaultInlineCount;
3538
+ if (sectionCount <= 3) {
3539
+ defaultInlineCount = 0;
3540
+ } else if (sectionCount <= 6) {
3541
+ defaultInlineCount = 1;
3542
+ } else {
3543
+ defaultInlineCount = 2;
3544
+ }
3545
+ const availableInlineCount = Math.min(defaultInlineCount, plan.inlineImages.length, sectionCount);
3546
+ let inlineCount;
3547
+ const maxImages = options?.maxImages;
3548
+ if (maxImages !== void 0 && maxImages >= 1) {
3549
+ inlineCount = Math.min(availableInlineCount, Math.max(0, maxImages - 1));
3550
+ } else {
3551
+ inlineCount = availableInlineCount;
3552
+ }
3553
+ const slots = [
3554
+ {
3555
+ id: "cover",
3556
+ kind: "cover",
3557
+ prompt: "",
3558
+ description: plan.coverImageDescription,
3559
+ anchorAfterSection: null
3560
+ }
3561
+ ];
3562
+ for (let i = 0; i < inlineCount; i++) {
3563
+ const anchorAfterSection = Math.max(
3564
+ 1,
3565
+ Math.min(sectionCount, Math.round((i + 1) / (inlineCount + 1) * sectionCount))
3566
+ );
3567
+ slots.push({
3568
+ id: `inline-${i + 1}`,
3569
+ kind: "inline",
3570
+ prompt: "",
3571
+ description: plan.inlineImages[i]?.description ?? "",
3572
+ anchorAfterSection
3573
+ });
3574
+ }
3575
+ return slots;
3576
+ }
3523
3577
  async function expandImagePrompts({
3524
- plan,
3578
+ slots,
3579
+ planContext,
3580
+ sections,
3525
3581
  settings,
3526
3582
  openRouter,
3527
3583
  dryRun,
@@ -3529,11 +3585,11 @@ async function expandImagePrompts({
3529
3585
  onPromptComplete,
3530
3586
  onInteraction
3531
3587
  }) {
3532
- const imageSlots = buildImageSlots(plan);
3533
3588
  const prompts = [];
3534
- for (let index = 0; index < imageSlots.length; index += 1) {
3535
- const image = imageSlots[index];
3536
- onProgress?.(`Expanding prompt ${index + 1}/${imageSlots.length}: ${image.kind === "cover" ? "cover image" : image.description}`);
3589
+ for (let index = 0; index < slots.length; index += 1) {
3590
+ const image = slots[index];
3591
+ onProgress?.(`Expanding prompt ${index + 1}/${slots.length}: ${image.kind === "cover" ? "cover image" : image.description}`);
3592
+ const sectionForImage = image.kind === "inline" && image.anchorAfterSection != null && sections ? sections[image.anchorAfterSection - 1] : void 0;
3537
3593
  if (dryRun || !openRouter) {
3538
3594
  const dryRunStartMs = Date.now();
3539
3595
  prompts.push({
@@ -3560,7 +3616,7 @@ async function expandImagePrompts({
3560
3616
  const response = await openRouter.requestStructured({
3561
3617
  schemaName: "image_prompt",
3562
3618
  schema: imagePromptSchema,
3563
- messages: buildImagePromptMessages(plan, image),
3619
+ messages: buildImagePromptMessages(planContext, image, sectionForImage),
3564
3620
  settings,
3565
3621
  interactionContext: {
3566
3622
  stageId: "image-prompts",
@@ -3776,24 +3832,6 @@ function mergeLlmMetrics(left, right) {
3776
3832
  }
3777
3833
  };
3778
3834
  }
3779
- function buildImageSlots(plan) {
3780
- return [
3781
- {
3782
- id: "cover",
3783
- kind: "cover",
3784
- prompt: "",
3785
- description: plan.coverImageDescription,
3786
- anchorAfterSection: null
3787
- },
3788
- ...plan.inlineImages.map((image, index) => ({
3789
- id: `inline-${index + 1}`,
3790
- kind: "inline",
3791
- prompt: "",
3792
- description: image.description,
3793
- anchorAfterSection: image.anchorAfterSection
3794
- }))
3795
- ];
3796
- }
3797
3835
  function createReplicateInput(settings, prompt, kind) {
3798
3836
  const model = getT2IModel(settings.t2i.modelId);
3799
3837
  const overrides = sanitizeT2IOverrides(settings.t2i.modelId, settings.t2i.inputOverrides);
@@ -4951,7 +4989,9 @@ async function runPipelineShell(input, options = {}) {
4951
4989
  options.onUpdate?.(cloneStages(stages));
4952
4990
  } else {
4953
4991
  imagePrompts = await expandImagePrompts({
4954
- plan,
4992
+ slots: selectImageSlots(plan, text.sections, { maxImages: options.maxImages }),
4993
+ planContext: plan,
4994
+ sections: text.sections,
4955
4995
  settings: input.config.settings,
4956
4996
  openRouter,
4957
4997
  dryRun,
@@ -6316,98 +6356,590 @@ function resolveCustomLinks(existing, addRaw, removeExpressions) {
6316
6356
  return Array.from(result.values());
6317
6357
  }
6318
6358
 
6319
- // src/cli/commands/writeTargetSpecs.ts
6320
- function parseTargetSpec(spec) {
6321
- const trimmed = spec.trim();
6322
- if (!trimmed) {
6323
- throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
6359
+ // src/cli/commands/export.ts
6360
+ import { copyFile, mkdir as mkdir6, readFile as readFile8, stat as stat5, writeFile as writeFile6 } from "fs/promises";
6361
+ import path11 from "path";
6362
+
6363
+ // src/output/enrichMarkdownWithLinks.ts
6364
+ function enrichMarkdownWithLinks(markdown, links) {
6365
+ if (links.length === 0) {
6366
+ return markdown;
6324
6367
  }
6325
- const [rawType, rawCount] = trimmed.split("=");
6326
- if (!rawType || !rawCount) {
6327
- throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
6368
+ const sorted = [...links].sort((left, right) => right.expression.length - left.expression.length);
6369
+ let updated = markdown;
6370
+ for (const link of sorted) {
6371
+ const escapedExpression = escapeRegExp(link.expression);
6372
+ const leadBoundary = /^\w/.test(link.expression) ? "\\b" : "";
6373
+ const trailBoundary = /\w$/.test(link.expression) ? "\\b" : "";
6374
+ const expressionRegex = new RegExp(`${leadBoundary}${escapedExpression}${trailBoundary}`, "g");
6375
+ let match;
6376
+ while ((match = expressionRegex.exec(updated)) !== null) {
6377
+ const start = match.index;
6378
+ const end = start + match[0].length;
6379
+ if (isInProtectedSpan(updated, start, end)) {
6380
+ continue;
6381
+ }
6382
+ updated = `${updated.slice(0, start)}[${match[0]}](${link.url})${updated.slice(end)}`;
6383
+ break;
6384
+ }
6328
6385
  }
6329
- const contentType = rawType.trim();
6330
- if (!contentTypeValues.includes(contentType)) {
6331
- throw new ReportedError(
6332
- `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6333
- );
6386
+ return updated;
6387
+ }
6388
+ function isInProtectedSpan(content, start, end) {
6389
+ const lineStart = content.lastIndexOf("\n", start) + 1;
6390
+ const lineEndIdx = content.indexOf("\n", end);
6391
+ const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx;
6392
+ const line = content.slice(lineStart, lineEnd);
6393
+ const linkPattern = /\[[^\]]*\]\([^)]*\)/g;
6394
+ let m;
6395
+ while ((m = linkPattern.exec(line)) !== null) {
6396
+ const absStart = lineStart + m.index;
6397
+ const absEnd = absStart + m[0].length;
6398
+ if (start >= absStart && end <= absEnd) {
6399
+ return true;
6400
+ }
6334
6401
  }
6335
- const count = Number.parseInt(rawCount.trim(), 10);
6336
- if (!Number.isFinite(count) || count <= 0) {
6337
- throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
6402
+ const codePattern = /`[^`]+`/g;
6403
+ while ((m = codePattern.exec(line)) !== null) {
6404
+ const absStart = lineStart + m.index;
6405
+ const absEnd = absStart + m[0].length;
6406
+ if (start >= absStart && end <= absEnd) {
6407
+ return true;
6408
+ }
6338
6409
  }
6339
- return {
6340
- contentType,
6341
- count
6342
- };
6410
+ return false;
6343
6411
  }
6344
- function parsePrimaryAndSecondarySpecs(options) {
6345
- const { primarySpec, secondarySpecs } = options;
6346
- if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6347
- return void 0;
6412
+ function escapeRegExp(value2) {
6413
+ return value2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6414
+ }
6415
+
6416
+ // src/server/previewHelpers.ts
6417
+ import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
6418
+ import path10 from "path";
6419
+ var DEFAULT_PORT = 4173;
6420
+ var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
6421
+ var FILE_PREFIX_TO_CONTENT_TYPE = {
6422
+ article: "article",
6423
+ blog: "blog-post",
6424
+ "x-thread": "x-thread",
6425
+ "x-post": "x-post",
6426
+ x: "x-post",
6427
+ reddit: "reddit-post",
6428
+ linkedin: "linkedin-post",
6429
+ newsletter: "newsletter"
6430
+ };
6431
+ var CONTENT_TYPE_LABELS = {
6432
+ article: "Article",
6433
+ "blog-post": "Blog Post",
6434
+ "x-thread": "X Thread",
6435
+ "x-post": "X Post",
6436
+ "reddit-post": "Reddit Post",
6437
+ "linkedin-post": "LinkedIn Post",
6438
+ newsletter: "Newsletter"
6439
+ };
6440
+ function parsePort(portOption) {
6441
+ if (!portOption) {
6442
+ return DEFAULT_PORT;
6348
6443
  }
6349
- if (!primarySpec) {
6350
- throw new ReportedError("Missing required --primary <content-type=count>.");
6444
+ const port = Number.parseInt(portOption, 10);
6445
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
6446
+ throw new Error(`Invalid port "${portOption}". Choose a value between 1 and 65535.`);
6351
6447
  }
6352
- const primary = parseTargetSpec(primarySpec);
6353
- if (primary.count !== 1) {
6354
- throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
6448
+ return port;
6449
+ }
6450
+ function stripFrontmatter2(markdown) {
6451
+ return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
6452
+ }
6453
+ function extractFrontmatterSlug(markdown) {
6454
+ const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
6455
+ const frontmatter = frontmatterMatch?.[1];
6456
+ if (!frontmatter) {
6457
+ return null;
6355
6458
  }
6356
- const secondaryDedupedByType = /* @__PURE__ */ new Map();
6357
- for (const spec of secondarySpecs ?? []) {
6358
- const parsed = parseTargetSpec(spec);
6359
- if (parsed.contentType === primary.contentType) {
6360
- throw new ReportedError(
6361
- `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6362
- );
6459
+ const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
6460
+ const rawSlug = slugMatch?.[1]?.trim();
6461
+ if (!rawSlug) {
6462
+ return null;
6463
+ }
6464
+ const unquoted = rawSlug.replace(/^['\"]|['\"]$/g, "").trim();
6465
+ return unquoted.length > 0 ? unquoted : null;
6466
+ }
6467
+ function extractHeadingTitle(markdown) {
6468
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
6469
+ if (!headingMatch || !headingMatch[1]) {
6470
+ return null;
6471
+ }
6472
+ return headingMatch[1].trim();
6473
+ }
6474
+ async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
6475
+ if (markdownPathArg) {
6476
+ const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
6477
+ if (path10.extname(resolved).toLowerCase() !== ".md") {
6478
+ throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
6363
6479
  }
6364
- const previous = secondaryDedupedByType.get(parsed.contentType);
6365
- if (previous) {
6366
- previous.count += parsed.count;
6367
- continue;
6480
+ await assertFileExists(resolved, "Could not find markdown file");
6481
+ return resolved;
6482
+ }
6483
+ return await resolveLatestMarkdown(markdownOutputDir);
6484
+ }
6485
+ async function resolveLatestMarkdown(markdownOutputDir) {
6486
+ const markdownCandidates = await findMarkdownFiles(markdownOutputDir);
6487
+ if (markdownCandidates.length === 0) {
6488
+ throw new Error(
6489
+ `No generated articles found in ${markdownOutputDir}. Run ideon write "your idea" first or pass a markdown path.`
6490
+ );
6491
+ }
6492
+ let latestPath = markdownCandidates[0];
6493
+ let latestMtime = 0;
6494
+ for (const candidate of markdownCandidates) {
6495
+ const fileStat = await stat4(candidate);
6496
+ if (fileStat.mtimeMs >= latestMtime) {
6497
+ latestMtime = fileStat.mtimeMs;
6498
+ latestPath = candidate;
6368
6499
  }
6369
- secondaryDedupedByType.set(parsed.contentType, {
6370
- ...parsed,
6371
- role: "secondary"
6372
- });
6373
6500
  }
6374
- return [
6375
- {
6376
- ...primary,
6377
- role: "primary"
6378
- },
6379
- ...secondaryDedupedByType.values()
6380
- ];
6501
+ return latestPath;
6381
6502
  }
6382
-
6383
- // src/integrations/mcp/server.ts
6384
- async function startIdeonMcpServer() {
6385
- const server = new McpServer({
6386
- name: "ideon",
6387
- version: package_default.version
6388
- });
6389
- server.registerTool(
6390
- "ideon_write",
6391
- {
6392
- title: "Ideon Write",
6393
- description: "Generate content from an idea using the Ideon pipeline.",
6394
- inputSchema: writeToolInputSchema
6395
- },
6396
- async (input) => {
6397
- try {
6398
- const parsedTargets = parsePrimaryAndSecondarySpecs({
6399
- primarySpec: input.primary,
6400
- secondarySpecs: input.secondary
6401
- });
6402
- const resolved = await resolveRunInput({
6403
- idea: input.idea,
6404
- audience: input.audience,
6405
- jobPath: input.jobPath,
6406
- style: input.style,
6407
- intent: input.intent,
6408
- targetLength: input.length,
6409
- contentTargets: parsedTargets
6410
- });
6503
+ async function assertFileExists(filePath, errorPrefix) {
6504
+ try {
6505
+ const fileStat = await stat4(filePath);
6506
+ if (!fileStat.isFile()) {
6507
+ throw new Error(`${errorPrefix}: ${filePath}`);
6508
+ }
6509
+ } catch {
6510
+ throw new Error(`${errorPrefix}: ${filePath}`);
6511
+ }
6512
+ }
6513
+ function extractCoverImageUrl(markdown) {
6514
+ const body = stripFrontmatter2(markdown);
6515
+ const match = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
6516
+ return match?.[1] ?? null;
6517
+ }
6518
+ async function extractArticleMetadata(markdownPath) {
6519
+ const markdown = await readFile7(markdownPath, "utf8");
6520
+ const fileStat = await stat4(markdownPath);
6521
+ const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
6522
+ const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
6523
+ const body = stripFrontmatter2(markdown);
6524
+ const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
6525
+ const coverImageUrl = extractCoverImageUrl(markdown);
6526
+ return {
6527
+ slug,
6528
+ title,
6529
+ mtime: fileStat.mtimeMs,
6530
+ previewSnippet,
6531
+ coverImageUrl
6532
+ };
6533
+ }
6534
+ async function listAllGenerations(markdownOutputDir) {
6535
+ const markdownFiles = await findMarkdownFiles(markdownOutputDir);
6536
+ const grouped = /* @__PURE__ */ new Map();
6537
+ for (const filePath of markdownFiles) {
6538
+ try {
6539
+ const metadata = await extractArticleMetadata(filePath);
6540
+ const identity = deriveOutputIdentity(filePath, markdownOutputDir);
6541
+ const output = {
6542
+ id: `${identity.generationId}:${identity.contentType}:${identity.index}`,
6543
+ generationId: identity.generationId,
6544
+ sourcePath: filePath,
6545
+ slug: metadata.slug,
6546
+ title: metadata.title,
6547
+ previewSnippet: metadata.previewSnippet,
6548
+ coverImageUrl: metadata.coverImageUrl,
6549
+ mtime: metadata.mtime,
6550
+ contentType: identity.contentType,
6551
+ contentTypeLabel: toContentTypeLabel(identity.contentType),
6552
+ index: identity.index
6553
+ };
6554
+ const existing = grouped.get(identity.generationId);
6555
+ if (existing) {
6556
+ existing.push(output);
6557
+ } else {
6558
+ grouped.set(identity.generationId, [output]);
6559
+ }
6560
+ } catch {
6561
+ }
6562
+ }
6563
+ const generations = [];
6564
+ for (const [id, outputs] of grouped.entries()) {
6565
+ outputs.sort((a, b) => compareContentTypes(a.contentType, b.contentType) || a.index - b.index || b.mtime - a.mtime);
6566
+ const primaryContentType = await resolvePrimaryContentType(outputs);
6567
+ const primary = outputs.find((output) => output.contentType === primaryContentType) ?? outputs[0];
6568
+ if (!primary) {
6569
+ continue;
6570
+ }
6571
+ const newestMtime = outputs.reduce((latest, output) => Math.max(latest, output.mtime), 0);
6572
+ generations.push({
6573
+ id,
6574
+ title: primary.title,
6575
+ mtime: newestMtime,
6576
+ previewSnippet: primary.previewSnippet,
6577
+ coverImageUrl: primary.coverImageUrl ?? outputs.find((output) => Boolean(output.coverImageUrl))?.coverImageUrl ?? null,
6578
+ primaryContentType,
6579
+ outputs
6580
+ });
6581
+ }
6582
+ generations.sort((a, b) => b.mtime - a.mtime);
6583
+ return generations;
6584
+ }
6585
+ function deriveGenerationId(markdownPath, markdownOutputDir) {
6586
+ const relative = path10.relative(markdownOutputDir, markdownPath);
6587
+ const normalized = relative.split(path10.sep).join("/");
6588
+ if (!normalized || normalized.startsWith("../")) {
6589
+ return path10.basename(markdownPath, ".md");
6590
+ }
6591
+ const segments = normalized.split("/").filter(Boolean);
6592
+ if (segments.length <= 1) {
6593
+ return path10.basename(markdownPath, ".md");
6594
+ }
6595
+ return segments[0] ?? path10.basename(markdownPath, ".md");
6596
+ }
6597
+ async function findMarkdownFiles(markdownOutputDir) {
6598
+ const files = [];
6599
+ const stack = [markdownOutputDir];
6600
+ while (stack.length > 0) {
6601
+ const current = stack.pop();
6602
+ if (!current) {
6603
+ continue;
6604
+ }
6605
+ let entries;
6606
+ try {
6607
+ entries = await readdir(current, { withFileTypes: true });
6608
+ } catch {
6609
+ continue;
6610
+ }
6611
+ for (const entry of entries) {
6612
+ const fullPath = path10.join(current, entry.name);
6613
+ if (entry.isDirectory()) {
6614
+ stack.push(fullPath);
6615
+ continue;
6616
+ }
6617
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
6618
+ files.push(fullPath);
6619
+ }
6620
+ }
6621
+ }
6622
+ return files;
6623
+ }
6624
+ function deriveOutputIdentity(markdownPath, markdownOutputDir) {
6625
+ const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
6626
+ const fileBase = path10.basename(markdownPath, ".md");
6627
+ const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
6628
+ if (!parsed || !parsed[1] || !parsed[2]) {
6629
+ return {
6630
+ generationId,
6631
+ contentType: "article",
6632
+ index: 1
6633
+ };
6634
+ }
6635
+ const prefix = parsed[1].toLowerCase();
6636
+ const index = Number.parseInt(parsed[2], 10);
6637
+ return {
6638
+ generationId,
6639
+ contentType: FILE_PREFIX_TO_CONTENT_TYPE[prefix] ?? prefix,
6640
+ index: Number.isFinite(index) && index > 0 ? index : 1
6641
+ };
6642
+ }
6643
+ function compareContentTypes(left, right) {
6644
+ const leftIndex = CONTENT_TYPE_ORDER.indexOf(left);
6645
+ const rightIndex = CONTENT_TYPE_ORDER.indexOf(right);
6646
+ const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
6647
+ const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
6648
+ if (normalizedLeft !== normalizedRight) {
6649
+ return normalizedLeft - normalizedRight;
6650
+ }
6651
+ return left.localeCompare(right);
6652
+ }
6653
+ function toContentTypeLabel(contentType) {
6654
+ const knownLabel = CONTENT_TYPE_LABELS[contentType];
6655
+ if (knownLabel) {
6656
+ return knownLabel;
6657
+ }
6658
+ return contentType.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
6659
+ }
6660
+ async function resolvePrimaryContentType(outputs) {
6661
+ const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
6662
+ const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
6663
+ if (!generationDir) {
6664
+ return fallback;
6665
+ }
6666
+ const jobPath = path10.join(generationDir, "job.json");
6667
+ try {
6668
+ const raw = await readFile7(jobPath, "utf8");
6669
+ const parsed = JSON.parse(raw);
6670
+ const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
6671
+ const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
6672
+ if (primary && typeof primary.contentType === "string") {
6673
+ return primary.contentType;
6674
+ }
6675
+ } catch {
6676
+ return fallback;
6677
+ }
6678
+ return fallback;
6679
+ }
6680
+
6681
+ // src/cli/commands/export.ts
6682
+ async function runOutputCommand(options, dependencies = {}) {
6683
+ const cwd2 = dependencies.cwd ?? process.cwd();
6684
+ const log = dependencies.log ?? ((message) => console.log(message));
6685
+ const targetIndex = options.index ?? 1;
6686
+ const resolved = await resolveRunInput({ idea: `Export generation ${options.generationId}` });
6687
+ const outputPaths = resolveOutputPaths(resolved.config.settings, cwd2);
6688
+ const generations = await listAllGenerations(outputPaths.markdownOutputDir);
6689
+ const generation = resolveGeneration(generations, options.generationId);
6690
+ const articleOutputs = generation.outputs.filter((output) => output.contentType === generation.primaryContentType);
6691
+ if (articleOutputs.length === 0) {
6692
+ throw new ReportedError(
6693
+ `Generation "${generation.id}" has no primary content outputs (type: ${generation.primaryContentType}).`
6694
+ );
6695
+ }
6696
+ const articleOutput = articleOutputs.find((output) => output.index === targetIndex);
6697
+ if (!articleOutput) {
6698
+ const available = articleOutputs.map((output) => output.index).join(", ");
6699
+ throw new ReportedError(
6700
+ `Generation "${generation.id}" has no primary output at index ${targetIndex}. Available: ${available}.`
6701
+ );
6702
+ }
6703
+ const sourceMarkdownPath = articleOutput.sourcePath;
6704
+ const sourceMarkdown = await readFile8(sourceMarkdownPath, "utf8");
6705
+ const slug = extractFrontmatterSlug2(sourceMarkdown) ?? path11.basename(sourceMarkdownPath, ".md");
6706
+ const exportFilename = `${slug}.md`;
6707
+ const destinationDir = await resolveDestinationDir(options.destinationPath, cwd2);
6708
+ const destinationFilePath = path11.join(destinationDir, exportFilename);
6709
+ if (!options.overwrite && await fileExists2(destinationFilePath)) {
6710
+ throw new ReportedError(
6711
+ `Export file already exists: ${destinationFilePath}. Pass --overwrite to replace it.`
6712
+ );
6713
+ }
6714
+ await mkdir6(destinationDir, { recursive: true });
6715
+ const links = await loadLinks(sourceMarkdownPath);
6716
+ const enrichedMarkdown = enrichWithFrontmatterGuard(sourceMarkdown, links);
6717
+ const sourceDir = path11.dirname(sourceMarkdownPath);
6718
+ const imagePaths = extractLocalImagePaths(sourceMarkdown);
6719
+ const copiedImages = [];
6720
+ for (const relImagePath of imagePaths) {
6721
+ const absoluteImageSrc = path11.resolve(sourceDir, relImagePath);
6722
+ let imageStat = null;
6723
+ try {
6724
+ imageStat = await stat5(absoluteImageSrc);
6725
+ } catch {
6726
+ throw new ReportedError(
6727
+ `Referenced image not found: ${relImagePath} (resolved to ${absoluteImageSrc}).`
6728
+ );
6729
+ }
6730
+ if (!imageStat.isFile()) {
6731
+ throw new ReportedError(`Referenced image path is not a file: ${absoluteImageSrc}.`);
6732
+ }
6733
+ const destImagePath = path11.join(destinationDir, relImagePath);
6734
+ await mkdir6(path11.dirname(destImagePath), { recursive: true });
6735
+ await copyFile(absoluteImageSrc, destImagePath);
6736
+ copiedImages.push(relImagePath);
6737
+ }
6738
+ await writeFile6(destinationFilePath, enrichedMarkdown, "utf8");
6739
+ const relDest = path11.relative(cwd2, destinationFilePath);
6740
+ log(`Exported "${generation.id}" (${generation.primaryContentType} #${targetIndex}) \u2192 ${relDest}`);
6741
+ if (copiedImages.length > 0) {
6742
+ log(`Copied ${copiedImages.length} image${copiedImages.length === 1 ? "" : "s"}: ${copiedImages.join(", ")}`);
6743
+ }
6744
+ if (links.length > 0) {
6745
+ log(`Injected ${links.length} inline link${links.length === 1 ? "" : "s"}.`);
6746
+ }
6747
+ }
6748
+ function resolveGeneration(generations, generationId) {
6749
+ const exact = generations.find((g) => g.id === generationId);
6750
+ if (exact) {
6751
+ return exact;
6752
+ }
6753
+ const bySlug = generations.find(
6754
+ (g) => g.outputs.some((output) => output.slug === generationId)
6755
+ );
6756
+ if (bySlug) {
6757
+ return bySlug;
6758
+ }
6759
+ throw new ReportedError(
6760
+ `Generation "${generationId}" not found. Run \`ideon preview\` to list available generations.`
6761
+ );
6762
+ }
6763
+ async function resolveDestinationDir(destinationPath, cwd2) {
6764
+ const resolved = path11.isAbsolute(destinationPath) ? destinationPath : path11.resolve(cwd2, destinationPath);
6765
+ return resolved;
6766
+ }
6767
+ async function fileExists2(filePath) {
6768
+ try {
6769
+ const fileStat = await stat5(filePath);
6770
+ return fileStat.isFile();
6771
+ } catch {
6772
+ return false;
6773
+ }
6774
+ }
6775
+ async function loadLinks(markdownPath) {
6776
+ const linksPath = resolveLinksPath(markdownPath);
6777
+ let raw;
6778
+ try {
6779
+ raw = await readFile8(linksPath, "utf8");
6780
+ } catch {
6781
+ return [];
6782
+ }
6783
+ let parsed;
6784
+ try {
6785
+ parsed = JSON.parse(raw);
6786
+ } catch {
6787
+ return [];
6788
+ }
6789
+ if (typeof parsed !== "object" || parsed === null) {
6790
+ return [];
6791
+ }
6792
+ const record = parsed;
6793
+ const links = Array.isArray(record.links) ? record.links : [];
6794
+ const customLinks = Array.isArray(record.customLinks) ? record.customLinks : [];
6795
+ const combined = [...customLinks, ...links];
6796
+ return combined.filter((entry) => {
6797
+ if (typeof entry !== "object" || entry === null) {
6798
+ return false;
6799
+ }
6800
+ const e = entry;
6801
+ return typeof e.expression === "string" && typeof e.url === "string" && (e.title === null || typeof e.title === "string");
6802
+ }).map((entry) => ({
6803
+ expression: entry.expression.trim(),
6804
+ url: entry.url.trim(),
6805
+ title: entry.title
6806
+ })).filter((entry) => entry.expression.length > 0 && entry.url.length > 0);
6807
+ }
6808
+ function enrichWithFrontmatterGuard(markdown, links) {
6809
+ if (links.length === 0) {
6810
+ return markdown;
6811
+ }
6812
+ const frontmatterMatch = markdown.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
6813
+ if (!frontmatterMatch) {
6814
+ return enrichMarkdownWithLinks(markdown, links);
6815
+ }
6816
+ const frontmatter = frontmatterMatch[0];
6817
+ const body = markdown.slice(frontmatter.length);
6818
+ return `${frontmatter}${enrichMarkdownWithLinks(body, links)}`;
6819
+ }
6820
+ function extractFrontmatterSlug2(markdown) {
6821
+ const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
6822
+ const block = frontmatterMatch?.[1];
6823
+ if (!block) {
6824
+ return null;
6825
+ }
6826
+ const slugMatch = block.match(/^slug:\s*(.+)$/m);
6827
+ const rawSlug = slugMatch?.[1]?.trim();
6828
+ if (!rawSlug) {
6829
+ return null;
6830
+ }
6831
+ const unquoted = rawSlug.replace(/^['""]|['""]$/g, "").trim();
6832
+ return unquoted.length > 0 ? unquoted : null;
6833
+ }
6834
+ function extractLocalImagePaths(markdown) {
6835
+ const imagePattern = /!\[[^\]]*\]\(([^)]+)\)/g;
6836
+ const paths = [];
6837
+ let match;
6838
+ while ((match = imagePattern.exec(markdown)) !== null) {
6839
+ const rawPath = match[1]?.trim();
6840
+ if (!rawPath) {
6841
+ continue;
6842
+ }
6843
+ if (rawPath.startsWith("http://") || rawPath.startsWith("https://") || rawPath.startsWith("data:") || rawPath.startsWith("/") || rawPath.startsWith("#")) {
6844
+ continue;
6845
+ }
6846
+ paths.push(rawPath);
6847
+ }
6848
+ return paths;
6849
+ }
6850
+
6851
+ // src/cli/commands/writeTargetSpecs.ts
6852
+ function parseTargetSpec(spec) {
6853
+ const trimmed = spec.trim();
6854
+ if (!trimmed) {
6855
+ throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
6856
+ }
6857
+ const [rawType, rawCount] = trimmed.split("=");
6858
+ if (!rawType || !rawCount) {
6859
+ throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
6860
+ }
6861
+ const contentType = rawType.trim();
6862
+ if (!contentTypeValues.includes(contentType)) {
6863
+ throw new ReportedError(
6864
+ `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6865
+ );
6866
+ }
6867
+ const count = Number.parseInt(rawCount.trim(), 10);
6868
+ if (!Number.isFinite(count) || count <= 0) {
6869
+ throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
6870
+ }
6871
+ return {
6872
+ contentType,
6873
+ count
6874
+ };
6875
+ }
6876
+ function parsePrimaryAndSecondarySpecs(options) {
6877
+ const { primarySpec, secondarySpecs } = options;
6878
+ if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6879
+ return void 0;
6880
+ }
6881
+ if (!primarySpec) {
6882
+ throw new ReportedError("Missing required --primary <content-type=count>.");
6883
+ }
6884
+ const primary = parseTargetSpec(primarySpec);
6885
+ if (primary.count !== 1) {
6886
+ throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
6887
+ }
6888
+ const secondaryDedupedByType = /* @__PURE__ */ new Map();
6889
+ for (const spec of secondarySpecs ?? []) {
6890
+ const parsed = parseTargetSpec(spec);
6891
+ if (parsed.contentType === primary.contentType) {
6892
+ throw new ReportedError(
6893
+ `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6894
+ );
6895
+ }
6896
+ const previous = secondaryDedupedByType.get(parsed.contentType);
6897
+ if (previous) {
6898
+ previous.count += parsed.count;
6899
+ continue;
6900
+ }
6901
+ secondaryDedupedByType.set(parsed.contentType, {
6902
+ ...parsed,
6903
+ role: "secondary"
6904
+ });
6905
+ }
6906
+ return [
6907
+ {
6908
+ ...primary,
6909
+ role: "primary"
6910
+ },
6911
+ ...secondaryDedupedByType.values()
6912
+ ];
6913
+ }
6914
+
6915
+ // src/integrations/mcp/server.ts
6916
+ async function startIdeonMcpServer() {
6917
+ const server = new McpServer({
6918
+ name: "ideon",
6919
+ version: package_default.version
6920
+ });
6921
+ server.registerTool(
6922
+ "ideon_write",
6923
+ {
6924
+ title: "Ideon Write",
6925
+ description: "Generate content from an idea using the Ideon pipeline.",
6926
+ inputSchema: writeToolInputSchema
6927
+ },
6928
+ async (input) => {
6929
+ try {
6930
+ const parsedTargets = parsePrimaryAndSecondarySpecs({
6931
+ primarySpec: input.primary,
6932
+ secondarySpecs: input.secondary
6933
+ });
6934
+ const resolved = await resolveRunInput({
6935
+ idea: input.idea,
6936
+ audience: input.audience,
6937
+ jobPath: input.jobPath,
6938
+ style: input.style,
6939
+ intent: input.intent,
6940
+ targetLength: input.length,
6941
+ contentTargets: parsedTargets
6942
+ });
6411
6943
  const run = await runPipelineShell(resolved, {
6412
6944
  workingDir: cwd(),
6413
6945
  runMode: "fresh",
@@ -6415,7 +6947,8 @@ async function startIdeonMcpServer() {
6415
6947
  enrichLinks: input.enrichLinks ?? false,
6416
6948
  customLinks: input.link,
6417
6949
  unlinks: input.unlink,
6418
- maxLinks: input.maxLinks
6950
+ maxLinks: input.maxLinks,
6951
+ maxImages: input.maxImages
6419
6952
  });
6420
6953
  return {
6421
6954
  content: [
@@ -6474,7 +7007,8 @@ async function startIdeonMcpServer() {
6474
7007
  enrichLinks: input.enrichLinks ?? false,
6475
7008
  customLinks: input.link,
6476
7009
  unlinks: input.unlink,
6477
- maxLinks: input.maxLinks
7010
+ maxLinks: input.maxLinks,
7011
+ maxImages: input.maxImages
6478
7012
  });
6479
7013
  return {
6480
7014
  content: [
@@ -6575,6 +7109,50 @@ async function startIdeonMcpServer() {
6575
7109
  }
6576
7110
  }
6577
7111
  );
7112
+ server.registerTool(
7113
+ "ideon_export",
7114
+ {
7115
+ title: "Ideon Export",
7116
+ description: "Export a generated article as a standalone markdown file with inline links and copied images.",
7117
+ inputSchema: exportToolInputZodSchema
7118
+ },
7119
+ async (input) => {
7120
+ try {
7121
+ const messages = [];
7122
+ await runOutputCommand(
7123
+ {
7124
+ generationId: input.generationId,
7125
+ destinationPath: input.destinationPath,
7126
+ index: input.index,
7127
+ overwrite: input.overwrite
7128
+ },
7129
+ {
7130
+ cwd: cwd(),
7131
+ log: (message) => {
7132
+ messages.push(message);
7133
+ }
7134
+ }
7135
+ );
7136
+ return {
7137
+ content: [
7138
+ {
7139
+ type: "text",
7140
+ text: messages.length > 0 ? messages.join("\n") : `Exported ${input.generationId}.`
7141
+ }
7142
+ ],
7143
+ structuredContent: {
7144
+ generationId: input.generationId,
7145
+ destinationPath: input.destinationPath,
7146
+ index: input.index ?? 1,
7147
+ overwrite: input.overwrite ?? false,
7148
+ messages
7149
+ }
7150
+ };
7151
+ } catch (error) {
7152
+ return formatToolError(error);
7153
+ }
7154
+ }
7155
+ );
6578
7156
  server.registerTool(
6579
7157
  "ideon_config_get",
6580
7158
  {
@@ -6940,476 +7518,156 @@ function applyEdit(action, value2, settings, secrets, setSettings, setSecrets) {
6940
7518
  setSettings({
6941
7519
  ...settings,
6942
7520
  notifications: {
6943
- ...settings.notifications,
6944
- enabled: parsed
6945
- }
6946
- });
6947
- return;
6948
- }
6949
- if (action === "temperature") {
6950
- const nextTemperature = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.temperature), 0, 2);
6951
- setSettings({
6952
- ...settings,
6953
- modelSettings: {
6954
- ...settings.modelSettings,
6955
- temperature: nextTemperature
6956
- }
6957
- });
6958
- return;
6959
- }
6960
- if (action === "maxTokens") {
6961
- const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6962
- setSettings({
6963
- ...settings,
6964
- modelSettings: {
6965
- ...settings.modelSettings,
6966
- maxTokens: nextMaxTokens
7521
+ ...settings.notifications,
7522
+ enabled: parsed
6967
7523
  }
6968
7524
  });
6969
7525
  return;
6970
7526
  }
6971
- if (action === "topP") {
6972
- const nextTopP = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.topP), 0, 1);
7527
+ if (action === "temperature") {
7528
+ const nextTemperature = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.temperature), 0, 2);
6973
7529
  setSettings({
6974
7530
  ...settings,
6975
7531
  modelSettings: {
6976
7532
  ...settings.modelSettings,
6977
- topP: nextTopP
7533
+ temperature: nextTemperature
6978
7534
  }
6979
7535
  });
6980
7536
  return;
6981
7537
  }
6982
- if (action === "markdownOutputDir") {
6983
- setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
6984
- return;
6985
- }
6986
- if (action === "assetOutputDir") {
6987
- setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
6988
- return;
6989
- }
6990
- if (action.startsWith("t2i:")) {
6991
- const fieldName = action.slice(4);
6992
- const parsedValue = coerceT2IFieldValue(settings.t2i.modelId, fieldName, value2);
6993
- const nextOverrides = { ...settings.t2i.inputOverrides };
6994
- if (parsedValue === void 0) {
6995
- delete nextOverrides[fieldName];
6996
- } else {
6997
- nextOverrides[fieldName] = parsedValue;
6998
- }
7538
+ if (action === "maxTokens") {
7539
+ const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6999
7540
  setSettings({
7000
7541
  ...settings,
7001
- t2i: {
7002
- ...settings.t2i,
7003
- inputOverrides: nextOverrides
7004
- }
7005
- });
7006
- }
7007
- }
7008
- function formatEditorValue(value2) {
7009
- if (value2 === null || value2 === void 0) {
7010
- return "";
7011
- }
7012
- return String(value2);
7013
- }
7014
- function formatValue(value2) {
7015
- if (value2 === null || value2 === void 0 || value2 === "") {
7016
- return "(default)";
7017
- }
7018
- return String(value2);
7019
- }
7020
- function parseNumberOrFallback(value2, fallback) {
7021
- const parsed = Number(value2.trim());
7022
- return Number.isFinite(parsed) ? parsed : fallback;
7023
- }
7024
- function clampNumber2(value2, minimum, maximum) {
7025
- return Math.min(maximum, Math.max(minimum, value2));
7026
- }
7027
- function parseBooleanOrFallback(value2, fallback) {
7028
- const normalized = value2.trim().toLowerCase();
7029
- if (normalized === "true") {
7030
- return true;
7031
- }
7032
- if (normalized === "false") {
7033
- return false;
7034
- }
7035
- return fallback;
7036
- }
7037
-
7038
- // src/cli/commands/settings.tsx
7039
- import { jsx as jsx2 } from "react/jsx-runtime";
7040
- async function openSettings() {
7041
- const envSettings = readEnvSettings();
7042
- const [settings, secrets] = await Promise.all([
7043
- loadSavedSettings(),
7044
- loadSecrets({ disableKeytar: envSettings.disableKeytar })
7045
- ]);
7046
- let result = null;
7047
- const app = render(
7048
- /* @__PURE__ */ jsx2(
7049
- SettingsFlow,
7050
- {
7051
- initialSettings: settings,
7052
- initialSecrets: secrets,
7053
- onDone: (value2) => {
7054
- result = value2;
7055
- }
7056
- }
7057
- )
7058
- );
7059
- await app.waitUntilExit();
7060
- const finalResult = result;
7061
- if (!finalResult) {
7062
- console.log("Settings unchanged.");
7063
- return;
7064
- }
7065
- const savedResult = finalResult;
7066
- await saveSettings(savedResult.settings);
7067
- try {
7068
- await saveSecrets(savedResult.secrets, { disableKeytar: envSettings.disableKeytar });
7069
- } catch (error) {
7070
- if (error instanceof KeytarUnavailableError) {
7071
- console.log("Settings saved, but secrets were not stored in the system keychain.");
7072
- console.log("Use IDEON_OPENROUTER_API_KEY and IDEON_REPLICATE_API_TOKEN in this environment.");
7073
- return;
7074
- }
7075
- throw error;
7076
- }
7077
- console.log(`Settings saved to ${getSettingsFilePath()}.`);
7078
- }
7079
-
7080
- // src/cli/commands/serve.ts
7081
- import path12 from "path";
7082
- import { spawn } from "child_process";
7083
-
7084
- // src/server/previewHelpers.ts
7085
- import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
7086
- import path10 from "path";
7087
- var DEFAULT_PORT = 4173;
7088
- var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
7089
- var FILE_PREFIX_TO_CONTENT_TYPE = {
7090
- article: "article",
7091
- blog: "blog-post",
7092
- "x-thread": "x-thread",
7093
- "x-post": "x-post",
7094
- x: "x-post",
7095
- reddit: "reddit-post",
7096
- linkedin: "linkedin-post",
7097
- newsletter: "newsletter"
7098
- };
7099
- var CONTENT_TYPE_LABELS = {
7100
- article: "Article",
7101
- "blog-post": "Blog Post",
7102
- "x-thread": "X Thread",
7103
- "x-post": "X Post",
7104
- "reddit-post": "Reddit Post",
7105
- "linkedin-post": "LinkedIn Post",
7106
- newsletter: "Newsletter"
7107
- };
7108
- function parsePort(portOption) {
7109
- if (!portOption) {
7110
- return DEFAULT_PORT;
7111
- }
7112
- const port = Number.parseInt(portOption, 10);
7113
- if (Number.isNaN(port) || port < 1 || port > 65535) {
7114
- throw new Error(`Invalid port "${portOption}". Choose a value between 1 and 65535.`);
7115
- }
7116
- return port;
7117
- }
7118
- function stripFrontmatter2(markdown) {
7119
- return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
7120
- }
7121
- function extractFrontmatterSlug(markdown) {
7122
- const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
7123
- const frontmatter = frontmatterMatch?.[1];
7124
- if (!frontmatter) {
7125
- return null;
7126
- }
7127
- const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
7128
- const rawSlug = slugMatch?.[1]?.trim();
7129
- if (!rawSlug) {
7130
- return null;
7131
- }
7132
- const unquoted = rawSlug.replace(/^['\"]|['\"]$/g, "").trim();
7133
- return unquoted.length > 0 ? unquoted : null;
7134
- }
7135
- function extractHeadingTitle(markdown) {
7136
- const headingMatch = markdown.match(/^#\s+(.+)$/m);
7137
- if (!headingMatch || !headingMatch[1]) {
7138
- return null;
7139
- }
7140
- return headingMatch[1].trim();
7141
- }
7142
- async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
7143
- if (markdownPathArg) {
7144
- const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
7145
- if (path10.extname(resolved).toLowerCase() !== ".md") {
7146
- throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
7147
- }
7148
- await assertFileExists(resolved, "Could not find markdown file");
7149
- return resolved;
7150
- }
7151
- return await resolveLatestMarkdown(markdownOutputDir);
7152
- }
7153
- async function resolveLatestMarkdown(markdownOutputDir) {
7154
- const markdownCandidates = await findMarkdownFiles(markdownOutputDir);
7155
- if (markdownCandidates.length === 0) {
7156
- throw new Error(
7157
- `No generated articles found in ${markdownOutputDir}. Run ideon write "your idea" first or pass a markdown path.`
7158
- );
7159
- }
7160
- let latestPath = markdownCandidates[0];
7161
- let latestMtime = 0;
7162
- for (const candidate of markdownCandidates) {
7163
- const fileStat = await stat4(candidate);
7164
- if (fileStat.mtimeMs >= latestMtime) {
7165
- latestMtime = fileStat.mtimeMs;
7166
- latestPath = candidate;
7167
- }
7168
- }
7169
- return latestPath;
7170
- }
7171
- async function assertFileExists(filePath, errorPrefix) {
7172
- try {
7173
- const fileStat = await stat4(filePath);
7174
- if (!fileStat.isFile()) {
7175
- throw new Error(`${errorPrefix}: ${filePath}`);
7176
- }
7177
- } catch {
7178
- throw new Error(`${errorPrefix}: ${filePath}`);
7179
- }
7180
- }
7181
- function extractCoverImageUrl(markdown) {
7182
- const body = stripFrontmatter2(markdown);
7183
- const match = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
7184
- return match?.[1] ?? null;
7185
- }
7186
- async function extractArticleMetadata(markdownPath) {
7187
- const markdown = await readFile7(markdownPath, "utf8");
7188
- const fileStat = await stat4(markdownPath);
7189
- const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
7190
- const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
7191
- const body = stripFrontmatter2(markdown);
7192
- const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
7193
- const coverImageUrl = extractCoverImageUrl(markdown);
7194
- return {
7195
- slug,
7196
- title,
7197
- mtime: fileStat.mtimeMs,
7198
- previewSnippet,
7199
- coverImageUrl
7200
- };
7201
- }
7202
- async function listAllGenerations(markdownOutputDir) {
7203
- const markdownFiles = await findMarkdownFiles(markdownOutputDir);
7204
- const grouped = /* @__PURE__ */ new Map();
7205
- for (const filePath of markdownFiles) {
7206
- try {
7207
- const metadata = await extractArticleMetadata(filePath);
7208
- const identity = deriveOutputIdentity(filePath, markdownOutputDir);
7209
- const output = {
7210
- id: `${identity.generationId}:${identity.contentType}:${identity.index}`,
7211
- generationId: identity.generationId,
7212
- sourcePath: filePath,
7213
- slug: metadata.slug,
7214
- title: metadata.title,
7215
- previewSnippet: metadata.previewSnippet,
7216
- coverImageUrl: metadata.coverImageUrl,
7217
- mtime: metadata.mtime,
7218
- contentType: identity.contentType,
7219
- contentTypeLabel: toContentTypeLabel(identity.contentType),
7220
- index: identity.index
7221
- };
7222
- const existing = grouped.get(identity.generationId);
7223
- if (existing) {
7224
- existing.push(output);
7225
- } else {
7226
- grouped.set(identity.generationId, [output]);
7542
+ modelSettings: {
7543
+ ...settings.modelSettings,
7544
+ maxTokens: nextMaxTokens
7227
7545
  }
7228
- } catch {
7229
- }
7546
+ });
7547
+ return;
7230
7548
  }
7231
- const generations = [];
7232
- for (const [id, outputs] of grouped.entries()) {
7233
- outputs.sort((a, b) => compareContentTypes(a.contentType, b.contentType) || a.index - b.index || b.mtime - a.mtime);
7234
- const primaryContentType = await resolvePrimaryContentType(outputs);
7235
- const primary = outputs.find((output) => output.contentType === primaryContentType) ?? outputs[0];
7236
- if (!primary) {
7237
- continue;
7238
- }
7239
- const newestMtime = outputs.reduce((latest, output) => Math.max(latest, output.mtime), 0);
7240
- generations.push({
7241
- id,
7242
- title: primary.title,
7243
- mtime: newestMtime,
7244
- previewSnippet: primary.previewSnippet,
7245
- coverImageUrl: primary.coverImageUrl ?? outputs.find((output) => Boolean(output.coverImageUrl))?.coverImageUrl ?? null,
7246
- primaryContentType,
7247
- outputs
7549
+ if (action === "topP") {
7550
+ const nextTopP = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.topP), 0, 1);
7551
+ setSettings({
7552
+ ...settings,
7553
+ modelSettings: {
7554
+ ...settings.modelSettings,
7555
+ topP: nextTopP
7556
+ }
7248
7557
  });
7558
+ return;
7249
7559
  }
7250
- generations.sort((a, b) => b.mtime - a.mtime);
7251
- return generations;
7252
- }
7253
- function deriveGenerationId(markdownPath, markdownOutputDir) {
7254
- const relative = path10.relative(markdownOutputDir, markdownPath);
7255
- const normalized = relative.split(path10.sep).join("/");
7256
- if (!normalized || normalized.startsWith("../")) {
7257
- return path10.basename(markdownPath, ".md");
7560
+ if (action === "markdownOutputDir") {
7561
+ setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
7562
+ return;
7258
7563
  }
7259
- const segments = normalized.split("/").filter(Boolean);
7260
- if (segments.length <= 1) {
7261
- return path10.basename(markdownPath, ".md");
7564
+ if (action === "assetOutputDir") {
7565
+ setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
7566
+ return;
7262
7567
  }
7263
- return segments[0] ?? path10.basename(markdownPath, ".md");
7264
- }
7265
- async function findMarkdownFiles(markdownOutputDir) {
7266
- const files = [];
7267
- const stack = [markdownOutputDir];
7268
- while (stack.length > 0) {
7269
- const current = stack.pop();
7270
- if (!current) {
7271
- continue;
7272
- }
7273
- let entries;
7274
- try {
7275
- entries = await readdir(current, { withFileTypes: true });
7276
- } catch {
7277
- continue;
7568
+ if (action.startsWith("t2i:")) {
7569
+ const fieldName = action.slice(4);
7570
+ const parsedValue = coerceT2IFieldValue(settings.t2i.modelId, fieldName, value2);
7571
+ const nextOverrides = { ...settings.t2i.inputOverrides };
7572
+ if (parsedValue === void 0) {
7573
+ delete nextOverrides[fieldName];
7574
+ } else {
7575
+ nextOverrides[fieldName] = parsedValue;
7278
7576
  }
7279
- for (const entry of entries) {
7280
- const fullPath = path10.join(current, entry.name);
7281
- if (entry.isDirectory()) {
7282
- stack.push(fullPath);
7283
- continue;
7284
- }
7285
- if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
7286
- files.push(fullPath);
7577
+ setSettings({
7578
+ ...settings,
7579
+ t2i: {
7580
+ ...settings.t2i,
7581
+ inputOverrides: nextOverrides
7287
7582
  }
7288
- }
7583
+ });
7289
7584
  }
7290
- return files;
7291
7585
  }
7292
- function deriveOutputIdentity(markdownPath, markdownOutputDir) {
7293
- const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
7294
- const fileBase = path10.basename(markdownPath, ".md");
7295
- const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
7296
- if (!parsed || !parsed[1] || !parsed[2]) {
7297
- return {
7298
- generationId,
7299
- contentType: "article",
7300
- index: 1
7301
- };
7586
+ function formatEditorValue(value2) {
7587
+ if (value2 === null || value2 === void 0) {
7588
+ return "";
7302
7589
  }
7303
- const prefix = parsed[1].toLowerCase();
7304
- const index = Number.parseInt(parsed[2], 10);
7305
- return {
7306
- generationId,
7307
- contentType: FILE_PREFIX_TO_CONTENT_TYPE[prefix] ?? prefix,
7308
- index: Number.isFinite(index) && index > 0 ? index : 1
7309
- };
7590
+ return String(value2);
7310
7591
  }
7311
- function compareContentTypes(left, right) {
7312
- const leftIndex = CONTENT_TYPE_ORDER.indexOf(left);
7313
- const rightIndex = CONTENT_TYPE_ORDER.indexOf(right);
7314
- const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
7315
- const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
7316
- if (normalizedLeft !== normalizedRight) {
7317
- return normalizedLeft - normalizedRight;
7592
+ function formatValue(value2) {
7593
+ if (value2 === null || value2 === void 0 || value2 === "") {
7594
+ return "(default)";
7318
7595
  }
7319
- return left.localeCompare(right);
7596
+ return String(value2);
7320
7597
  }
7321
- function toContentTypeLabel(contentType) {
7322
- const knownLabel = CONTENT_TYPE_LABELS[contentType];
7323
- if (knownLabel) {
7324
- return knownLabel;
7598
+ function parseNumberOrFallback(value2, fallback) {
7599
+ const parsed = Number(value2.trim());
7600
+ return Number.isFinite(parsed) ? parsed : fallback;
7601
+ }
7602
+ function clampNumber2(value2, minimum, maximum) {
7603
+ return Math.min(maximum, Math.max(minimum, value2));
7604
+ }
7605
+ function parseBooleanOrFallback(value2, fallback) {
7606
+ const normalized = value2.trim().toLowerCase();
7607
+ if (normalized === "true") {
7608
+ return true;
7325
7609
  }
7326
- return contentType.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
7610
+ if (normalized === "false") {
7611
+ return false;
7612
+ }
7613
+ return fallback;
7327
7614
  }
7328
- async function resolvePrimaryContentType(outputs) {
7329
- const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
7330
- const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
7331
- if (!generationDir) {
7332
- return fallback;
7615
+
7616
+ // src/cli/commands/settings.tsx
7617
+ import { jsx as jsx2 } from "react/jsx-runtime";
7618
+ async function openSettings() {
7619
+ const envSettings = readEnvSettings();
7620
+ const [settings, secrets] = await Promise.all([
7621
+ loadSavedSettings(),
7622
+ loadSecrets({ disableKeytar: envSettings.disableKeytar })
7623
+ ]);
7624
+ let result = null;
7625
+ const app = render(
7626
+ /* @__PURE__ */ jsx2(
7627
+ SettingsFlow,
7628
+ {
7629
+ initialSettings: settings,
7630
+ initialSecrets: secrets,
7631
+ onDone: (value2) => {
7632
+ result = value2;
7633
+ }
7634
+ }
7635
+ )
7636
+ );
7637
+ await app.waitUntilExit();
7638
+ const finalResult = result;
7639
+ if (!finalResult) {
7640
+ console.log("Settings unchanged.");
7641
+ return;
7333
7642
  }
7334
- const jobPath = path10.join(generationDir, "job.json");
7643
+ const savedResult = finalResult;
7644
+ await saveSettings(savedResult.settings);
7335
7645
  try {
7336
- const raw = await readFile7(jobPath, "utf8");
7337
- const parsed = JSON.parse(raw);
7338
- const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
7339
- const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
7340
- if (primary && typeof primary.contentType === "string") {
7341
- return primary.contentType;
7646
+ await saveSecrets(savedResult.secrets, { disableKeytar: envSettings.disableKeytar });
7647
+ } catch (error) {
7648
+ if (error instanceof KeytarUnavailableError) {
7649
+ console.log("Settings saved, but secrets were not stored in the system keychain.");
7650
+ console.log("Use IDEON_OPENROUTER_API_KEY and IDEON_REPLICATE_API_TOKEN in this environment.");
7651
+ return;
7342
7652
  }
7343
- } catch {
7344
- return fallback;
7653
+ throw error;
7345
7654
  }
7346
- return fallback;
7655
+ console.log(`Settings saved to ${getSettingsFilePath()}.`);
7347
7656
  }
7348
7657
 
7658
+ // src/cli/commands/serve.ts
7659
+ import path13 from "path";
7660
+ import { spawn } from "child_process";
7661
+
7349
7662
  // src/server/previewServer.ts
7350
7663
  import { execFile } from "child_process";
7351
7664
  import { promisify } from "util";
7352
- import { readFile as readFile8, stat as stat5 } from "fs/promises";
7665
+ import { readFile as readFile9, stat as stat6 } from "fs/promises";
7353
7666
  import { watch as fsWatch } from "fs";
7354
- import path11 from "path";
7667
+ import path12 from "path";
7355
7668
  import { fileURLToPath } from "url";
7356
7669
  import express from "express";
7357
7670
  import { marked } from "marked";
7358
-
7359
- // src/output/enrichMarkdownWithLinks.ts
7360
- function enrichMarkdownWithLinks(markdown, links) {
7361
- if (links.length === 0) {
7362
- return markdown;
7363
- }
7364
- const sorted = [...links].sort((left, right) => right.expression.length - left.expression.length);
7365
- let updated = markdown;
7366
- for (const link of sorted) {
7367
- const escapedExpression = escapeRegExp(link.expression);
7368
- const leadBoundary = /^\w/.test(link.expression) ? "\\b" : "";
7369
- const trailBoundary = /\w$/.test(link.expression) ? "\\b" : "";
7370
- const expressionRegex = new RegExp(`${leadBoundary}${escapedExpression}${trailBoundary}`, "g");
7371
- let match;
7372
- while ((match = expressionRegex.exec(updated)) !== null) {
7373
- const start = match.index;
7374
- const end = start + match[0].length;
7375
- if (isInProtectedSpan(updated, start, end)) {
7376
- continue;
7377
- }
7378
- updated = `${updated.slice(0, start)}[${match[0]}](${link.url})${updated.slice(end)}`;
7379
- break;
7380
- }
7381
- }
7382
- return updated;
7383
- }
7384
- function isInProtectedSpan(content, start, end) {
7385
- const lineStart = content.lastIndexOf("\n", start) + 1;
7386
- const lineEndIdx = content.indexOf("\n", end);
7387
- const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx;
7388
- const line = content.slice(lineStart, lineEnd);
7389
- const linkPattern = /\[[^\]]*\]\([^)]*\)/g;
7390
- let m;
7391
- while ((m = linkPattern.exec(line)) !== null) {
7392
- const absStart = lineStart + m.index;
7393
- const absEnd = absStart + m[0].length;
7394
- if (start >= absStart && end <= absEnd) {
7395
- return true;
7396
- }
7397
- }
7398
- const codePattern = /`[^`]+`/g;
7399
- while ((m = codePattern.exec(line)) !== null) {
7400
- const absStart = lineStart + m.index;
7401
- const absEnd = absStart + m[0].length;
7402
- if (start >= absStart && end <= absEnd) {
7403
- return true;
7404
- }
7405
- }
7406
- return false;
7407
- }
7408
- function escapeRegExp(value2) {
7409
- return value2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7410
- }
7411
-
7412
- // src/server/previewServer.ts
7413
7671
  var execFileAsync = promisify(execFile);
7414
7672
  var MissingArticleError = class extends Error {
7415
7673
  constructor(message) {
@@ -7504,7 +7762,7 @@ async function startPreviewServer(options) {
7504
7762
  if (options.watch) {
7505
7763
  let html2;
7506
7764
  try {
7507
- html2 = await readFile8(path11.join(previewClientDir, "index.html"), "utf8");
7765
+ html2 = await readFile9(path12.join(previewClientDir, "index.html"), "utf8");
7508
7766
  } catch {
7509
7767
  res.status(200).type("html").send(
7510
7768
  `<!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>`
@@ -7515,7 +7773,7 @@ async function startPreviewServer(options) {
7515
7773
  const injected = html2.replace("</body>", `${reloadScript}</body>`);
7516
7774
  res.status(200).type("html").send(injected);
7517
7775
  } else {
7518
- res.status(200).sendFile(path11.join(previewClientDir, "index.html"));
7776
+ res.status(200).sendFile(path12.join(previewClientDir, "index.html"));
7519
7777
  }
7520
7778
  return;
7521
7779
  }
@@ -7568,7 +7826,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7568
7826
  generation.outputs.map(async (output) => {
7569
7827
  let markdown = "";
7570
7828
  try {
7571
- markdown = await readFile8(output.sourcePath, "utf8");
7829
+ markdown = await readFile9(output.sourcePath, "utf8");
7572
7830
  } catch (error) {
7573
7831
  if (isMissingFileError(error)) {
7574
7832
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
@@ -7586,7 +7844,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7586
7844
  };
7587
7845
  })
7588
7846
  );
7589
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7847
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7590
7848
  const interactions = generationDir ? await loadSavedInteractions(generationDir) : { llmCalls: [], t2iCalls: [] };
7591
7849
  const analyticsSummary = generationDir ? await loadSavedAnalyticsSummary(generationDir) : null;
7592
7850
  return {
@@ -7615,7 +7873,7 @@ async function resolveActivePreviewArticle(preferredMarkdownPath, markdownOutput
7615
7873
  };
7616
7874
  }
7617
7875
  function resolveGenerationSourcePath(generation, markdownOutputDir) {
7618
- return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path11.join(markdownOutputDir, generation.id);
7876
+ return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path12.join(markdownOutputDir, generation.id);
7619
7877
  }
7620
7878
  function isMissingFileError(error) {
7621
7879
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -7630,7 +7888,7 @@ async function renderArticleHtml(markdown, generationId, sourcePath) {
7630
7888
  async function loadSavedLinks(markdownPath) {
7631
7889
  const linksPath = resolveLinksPath(markdownPath);
7632
7890
  try {
7633
- const raw = await readFile8(linksPath, "utf8");
7891
+ const raw = await readFile9(linksPath, "utf8");
7634
7892
  const parsed = JSON.parse(raw);
7635
7893
  if (!Array.isArray(parsed.links)) {
7636
7894
  return [];
@@ -7654,9 +7912,9 @@ async function loadSavedLinks(markdownPath) {
7654
7912
  }
7655
7913
  }
7656
7914
  async function loadSavedInteractions(generationDir) {
7657
- const interactionsPath = path11.join(generationDir, "model.interactions.json");
7915
+ const interactionsPath = path12.join(generationDir, "model.interactions.json");
7658
7916
  try {
7659
- const raw = await readFile8(interactionsPath, "utf8");
7917
+ const raw = await readFile9(interactionsPath, "utf8");
7660
7918
  const parsed = JSON.parse(raw);
7661
7919
  const llmCalls = Array.isArray(parsed.llmCalls) ? parsed.llmCalls.filter(isPreviewLlmInteraction) : [];
7662
7920
  const t2iCalls = Array.isArray(parsed.t2iCalls) ? parsed.t2iCalls.filter(isPreviewT2IInteraction) : [];
@@ -7672,9 +7930,9 @@ async function loadSavedInteractions(generationDir) {
7672
7930
  }
7673
7931
  }
7674
7932
  async function loadSavedAnalyticsSummary(generationDir) {
7675
- const analyticsPath = path11.join(generationDir, "generation.analytics.json");
7933
+ const analyticsPath = path12.join(generationDir, "generation.analytics.json");
7676
7934
  try {
7677
- const raw = await readFile8(analyticsPath, "utf8");
7935
+ const raw = await readFile9(analyticsPath, "utf8");
7678
7936
  const parsed = JSON.parse(raw);
7679
7937
  const summary = parsed.summary;
7680
7938
  if (!summary || typeof summary !== "object") {
@@ -7706,14 +7964,14 @@ async function getPreviewBootstrapData(preferredMarkdownPath, markdownOutputDir)
7706
7964
  };
7707
7965
  }
7708
7966
  async function resolvePreviewClientBuildDir() {
7709
- const currentDir = path11.dirname(fileURLToPath(import.meta.url));
7967
+ const currentDir = path12.dirname(fileURLToPath(import.meta.url));
7710
7968
  const candidates = [
7711
- path11.resolve(currentDir, "preview"),
7712
- path11.resolve(currentDir, "../../dist/preview")
7969
+ path12.resolve(currentDir, "preview"),
7970
+ path12.resolve(currentDir, "../../dist/preview")
7713
7971
  ];
7714
7972
  for (const candidate of candidates) {
7715
7973
  try {
7716
- const indexStat = await stat5(path11.join(candidate, "index.html"));
7974
+ const indexStat = await stat6(path12.join(candidate, "index.html"));
7717
7975
  if (indexStat.isFile()) {
7718
7976
  return candidate;
7719
7977
  }
@@ -7775,21 +8033,21 @@ async function resolveGenerationAssetPath(generationId, rawAssetPath, markdownOu
7775
8033
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
7776
8034
  }
7777
8035
  const decodedAssetPath = decodeURIComponent(rawAssetPath);
7778
- const normalizedRelative = path11.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7779
- if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path11.posix.isAbsolute(normalizedRelative)) {
8036
+ const normalizedRelative = path12.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
8037
+ if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path12.posix.isAbsolute(normalizedRelative)) {
7780
8038
  throw new Error("Invalid generation asset path.");
7781
8039
  }
7782
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
8040
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7783
8041
  if (!generationDir) {
7784
8042
  throw new MissingArticleError(`Generation "${generationId}" has no source directory.`);
7785
8043
  }
7786
- const resolvedPath = path11.resolve(generationDir, normalizedRelative);
7787
- const relativeToGeneration = path11.relative(generationDir, resolvedPath);
7788
- if (relativeToGeneration.startsWith("..") || path11.isAbsolute(relativeToGeneration)) {
8044
+ const resolvedPath = path12.resolve(generationDir, normalizedRelative);
8045
+ const relativeToGeneration = path12.relative(generationDir, resolvedPath);
8046
+ if (relativeToGeneration.startsWith("..") || path12.isAbsolute(relativeToGeneration)) {
7789
8047
  throw new Error("Invalid generation asset path.");
7790
8048
  }
7791
8049
  try {
7792
- const fileStat = await stat5(resolvedPath);
8050
+ const fileStat = await stat6(resolvedPath);
7793
8051
  if (!fileStat.isFile()) {
7794
8052
  throw new Error("Invalid generation asset path.");
7795
8053
  }
@@ -9269,7 +9527,7 @@ async function runServeCommand(options) {
9269
9527
  const markdownPath = await resolveMarkdownPath(options.markdownPath, outputPaths.markdownOutputDir, process.cwd());
9270
9528
  const port = parsePort(options.port);
9271
9529
  if (options.watch) {
9272
- const viteBin = path12.resolve(process.cwd(), "node_modules", ".bin", "vite");
9530
+ const viteBin = path13.resolve(process.cwd(), "node_modules", ".bin", "vite");
9273
9531
  const viteProcess = spawn(viteBin, ["build", "--watch"], {
9274
9532
  stdio: "inherit",
9275
9533
  shell: process.platform === "win32"
@@ -9295,8 +9553,8 @@ async function runServeCommand(options) {
9295
9553
  openBrowser: options.openBrowser,
9296
9554
  watch: options.watch
9297
9555
  });
9298
- const relativeArticle = path12.relative(process.cwd(), markdownPath);
9299
- const relativeAssets = path12.relative(process.cwd(), outputPaths.assetOutputDir);
9556
+ const relativeArticle = path13.relative(process.cwd(), markdownPath);
9557
+ const relativeAssets = path13.relative(process.cwd(), outputPaths.assetOutputDir);
9300
9558
  console.log(`Previewing ${relativeArticle || markdownPath}`);
9301
9559
  console.log(`Serving assets from ${relativeAssets || outputPaths.assetOutputDir}`);
9302
9560
  console.log(`Open ${server.url}`);
@@ -9782,7 +10040,7 @@ function formatPipelineStageCost(stage) {
9782
10040
  }
9783
10041
  return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9784
10042
  }
9785
- async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks) {
10043
+ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages) {
9786
10044
  let previousStages = /* @__PURE__ */ new Map();
9787
10045
  let previousItemStatuses = /* @__PURE__ */ new Map();
9788
10046
  const notificationsEnabled = input.config.settings.notifications.enabled;
@@ -9799,6 +10057,7 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links,
9799
10057
  customLinks: links,
9800
10058
  unlinks,
9801
10059
  maxLinks,
10060
+ maxImages,
9802
10061
  onUpdate(stages) {
9803
10062
  for (const stage of stages) {
9804
10063
  const previous = previousStages.get(stage.id);
@@ -10160,6 +10419,7 @@ function WriteApp({
10160
10419
  links,
10161
10420
  unlinks,
10162
10421
  maxLinks,
10422
+ maxImages,
10163
10423
  onError
10164
10424
  }) {
10165
10425
  const { exit } = useApp3();
@@ -10186,6 +10446,7 @@ function WriteApp({
10186
10446
  customLinks: links,
10187
10447
  unlinks,
10188
10448
  maxLinks,
10449
+ maxImages,
10189
10450
  onUpdate(nextStages) {
10190
10451
  if (mounted) {
10191
10452
  setStages(nextStages);
@@ -10218,7 +10479,7 @@ function WriteApp({
10218
10479
  return () => {
10219
10480
  mounted = false;
10220
10481
  };
10221
- }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, onError, runMode]);
10482
+ }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, maxImages, onError, runMode]);
10222
10483
  useEffect2(() => {
10223
10484
  if (!result && !errorMessage) {
10224
10485
  return;
@@ -10234,7 +10495,7 @@ function WriteApp({
10234
10495
  }
10235
10496
  async function runWriteCommand(options) {
10236
10497
  const input = await resolveInputWithInteractiveIdeaFallback(options);
10237
- await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks);
10498
+ await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks, options.maxImages);
10238
10499
  }
10239
10500
  async function runWriteResumeCommand(options = {}) {
10240
10501
  const session = await loadWriteSession();
@@ -10256,9 +10517,9 @@ async function runWriteResumeCommand(options = {}) {
10256
10517
  secrets: resolved.config.secrets
10257
10518
  }
10258
10519
  };
10259
- await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks);
10520
+ await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks, options.maxImages);
10260
10521
  }
10261
- async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks) {
10522
+ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks, maxImages) {
10262
10523
  let interruptHandled = false;
10263
10524
  const handleSignal = (signal) => {
10264
10525
  if (interruptHandled) {
@@ -10292,7 +10553,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10292
10553
  process.on("SIGTERM", onSigterm);
10293
10554
  try {
10294
10555
  if (noInteractive || !process.stdout.isTTY) {
10295
- await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks);
10556
+ await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages);
10296
10557
  return;
10297
10558
  }
10298
10559
  let commandError = null;
@@ -10307,6 +10568,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10307
10568
  links,
10308
10569
  unlinks,
10309
10570
  maxLinks,
10571
+ maxImages,
10310
10572
  onError: (error) => {
10311
10573
  commandError = error;
10312
10574
  }
@@ -10474,7 +10736,7 @@ function collectOptionValue(value2, previous = []) {
10474
10736
  }
10475
10737
  async function runCli(argv) {
10476
10738
  const program = new Command();
10477
- program.name("ideon").description("Turn ideas into rich Markdown articles with generated images.").version(version);
10739
+ program.name("ideon").description("Turn one idea into articles, threads, and social posts \u2014 quality content without the token tax.").version(version);
10478
10740
  program.command("settings").description("Show the current Ideon settings and storage state.").action(async () => {
10479
10741
  await openSettings();
10480
10742
  });
@@ -10519,6 +10781,14 @@ async function runCli(argv) {
10519
10781
  maxLinks: options.maxLinks
10520
10782
  });
10521
10783
  });
10784
+ program.command("export").description("Export a generated article as a standalone markdown file with inline links and copied images.").argument("<generationId>", "Generation id or article slug to export").argument("<path>", "Destination directory for the exported file and images").option("--index <n>", "Which primary article variant to export when multiple exist (default: 1)", (v) => Number.parseInt(v, 10)).option("--overwrite", "Overwrite the destination file if it already exists", false).action(async (generationId, destinationPath, options) => {
10785
+ await runOutputCommand({
10786
+ generationId,
10787
+ destinationPath,
10788
+ index: options.index,
10789
+ overwrite: options.overwrite
10790
+ });
10791
+ });
10522
10792
  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) => {
10523
10793
  await runServeCommand({
10524
10794
  markdownPath,
@@ -10527,7 +10797,7 @@ async function runCli(argv) {
10527
10797
  watch: options.watch
10528
10798
  });
10529
10799
  });
10530
- 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) => {
10800
+ 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)).option("--max-images <n>", "Max total images including cover (1=cover only, 2=cover+1 inline, 3=cover+2 inline)", (v) => Number.parseInt(v, 10)).action(async (ideaArg, options) => {
10531
10801
  await runWriteCommand({
10532
10802
  idea: options.idea ?? ideaArg,
10533
10803
  audience: options.audience,
@@ -10542,16 +10812,18 @@ async function runCli(argv) {
10542
10812
  enrichLinks: options.enrichLinks,
10543
10813
  links: options.link,
10544
10814
  unlinks: options.unlink,
10545
- maxLinks: options.maxLinks
10815
+ maxLinks: options.maxLinks,
10816
+ maxImages: options.maxImages
10546
10817
  });
10547
10818
  });
10548
- 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) => {
10819
+ 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)).option("--max-images <n>", "Max total images including cover (1=cover only, 2=cover+1 inline, 3=cover+2 inline)", (v) => Number.parseInt(v, 10)).action(async (options) => {
10549
10820
  await runWriteResumeCommand({
10550
10821
  noInteractive: options.noInteractive,
10551
10822
  enrichLinks: options.enrichLinks,
10552
10823
  links: options.link,
10553
10824
  unlinks: options.unlink,
10554
- maxLinks: options.maxLinks
10825
+ maxLinks: options.maxLinks,
10826
+ maxImages: options.maxImages
10555
10827
  });
10556
10828
  });
10557
10829
  await program.parseAsync(argv);