@telepat/ideon 0.1.16 → 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({})),
@@ -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"],
@@ -1348,7 +1362,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1348
1362
  // package.json
1349
1363
  var package_default = {
1350
1364
  name: "@telepat/ideon",
1351
- version: "0.1.16",
1365
+ version: "0.1.18",
1352
1366
  description: "CLI for generating rich articles and images from ideas.",
1353
1367
  type: "module",
1354
1368
  repository: {
@@ -2269,9 +2283,8 @@ function buildArticlePlanJsonSchema(targetLengthWords) {
2269
2283
  items: {
2270
2284
  type: "object",
2271
2285
  additionalProperties: false,
2272
- required: ["anchorAfterSection", "description"],
2286
+ required: ["description"],
2273
2287
  properties: {
2274
- anchorAfterSection: { type: "integer", minimum: 1, maximum: 10 },
2275
2288
  description: { type: "string" }
2276
2289
  }
2277
2290
  }
@@ -2310,7 +2323,7 @@ function buildArticlePlanMessages(idea, options) {
2310
2323
  "- Sections are article-only structure and must not be treated as requirements for non-article channels.",
2311
2324
  "- Include a cover image description and 2 to 3 inline image descriptions.",
2312
2325
  "- Image descriptions must be concrete and contextual, not generic stock-photo phrasing.",
2313
- "- 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.",
2314
2327
  "",
2315
2328
  "Shared content brief context:",
2316
2329
  `- description: ${options.contentBrief.description}`,
@@ -2329,7 +2342,7 @@ function buildArticlePlanMessages(idea, options) {
2329
2342
  "- outroBrief: string",
2330
2343
  `- sections: array of ${sectionCounts.label} objects, each with title and description strings`,
2331
2344
  "- coverImageDescription: string",
2332
- "- 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",
2333
2346
  "",
2334
2347
  "Do not omit any required fields. Return strict JSON only."
2335
2348
  ].join("\n")
@@ -2344,7 +2357,6 @@ var articleSectionPlanSchema = z5.object({
2344
2357
  description: z5.string().min(1)
2345
2358
  });
2346
2359
  var inlineImagePlanSchema = z5.object({
2347
- anchorAfterSection: z5.number().int().min(1).max(10),
2348
2360
  description: z5.string().min(1)
2349
2361
  });
2350
2362
  var articlePlanSchema = z5.object({
@@ -2400,7 +2412,7 @@ async function planArticle({
2400
2412
  ...basePlan,
2401
2413
  slug: uniqueSlug,
2402
2414
  keywords: basePlan.keywords.slice(0, 8),
2403
- inlineImages: basePlan.inlineImages.filter((image) => image.anchorAfterSection <= basePlan.sections.length).slice(0, 3)
2415
+ inlineImages: basePlan.inlineImages.slice(0, 3)
2404
2416
  };
2405
2417
  }
2406
2418
  function buildDryRunPlan(idea, contentBrief) {
@@ -2438,11 +2450,9 @@ function buildDryRunPlan(idea, contentBrief) {
2438
2450
  coverImageDescription: "A refined editorial workspace with notebooks, sketches, and glowing structured outlines, cinematic but minimal.",
2439
2451
  inlineImages: [
2440
2452
  {
2441
- anchorAfterSection: 1,
2442
2453
  description: "A rough idea evolving into a structured article outline on a desk full of notes."
2443
2454
  },
2444
2455
  {
2445
- anchorAfterSection: 3,
2446
2456
  description: "A collaborative editorial scene where human judgment and AI suggestions coexist."
2447
2457
  }
2448
2458
  ]
@@ -2937,7 +2947,21 @@ var imagePromptSchema = {
2937
2947
  prompt: { type: "string" }
2938
2948
  }
2939
2949
  };
2940
- 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.");
2941
2965
  return [
2942
2966
  {
2943
2967
  role: "system",
@@ -2945,14 +2969,7 @@ function buildImagePromptMessages(plan, image) {
2945
2969
  },
2946
2970
  {
2947
2971
  role: "user",
2948
- content: [
2949
- `Article title: ${plan.title}`,
2950
- `Article subtitle: ${plan.subtitle}`,
2951
- `Article description: ${plan.description}`,
2952
- `Image kind: ${image.kind}`,
2953
- `Image description: ${image.description}`,
2954
- "Write one strong prompt for a clean editorial illustration. Avoid text overlays or watermarks."
2955
- ].join("\n")
2972
+ content: userLines.join("\n")
2956
2973
  }
2957
2974
  ];
2958
2975
  }
@@ -3422,11 +3439,12 @@ function clampNumber(value2, minimum, maximum, integer, nullable) {
3422
3439
  // src/pipeline/analytics.ts
3423
3440
  var LLM_USD_PER_1K_TOKENS = {
3424
3441
  // AUTO-GENERATED:OPENROUTER_PRICING_START
3425
- // Last refreshed: 2026-03-27
3442
+ // Last refreshed: 2026-05-05
3426
3443
  // Source: https://openrouter.ai/api/v1/models (per-token USD converted to per-1k-token USD)
3427
3444
  "anthropic/claude-3.5-sonnet": { input: 6e-3, output: 0.03 },
3428
3445
  "deepseek/deepseek-chat": { input: 32e-5, output: 89e-5 },
3429
- "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 },
3430
3448
  "openai/gpt-4o-mini": { input: 15e-5, output: 6e-4 }
3431
3449
  // AUTO-GENERATED:OPENROUTER_PRICING_END
3432
3450
  };
@@ -3514,8 +3532,52 @@ function sumKnownCosts(values) {
3514
3532
 
3515
3533
  // src/images/renderImages.ts
3516
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
+ }
3517
3577
  async function expandImagePrompts({
3518
- plan,
3578
+ slots,
3579
+ planContext,
3580
+ sections,
3519
3581
  settings,
3520
3582
  openRouter,
3521
3583
  dryRun,
@@ -3523,11 +3585,11 @@ async function expandImagePrompts({
3523
3585
  onPromptComplete,
3524
3586
  onInteraction
3525
3587
  }) {
3526
- const imageSlots = buildImageSlots(plan);
3527
3588
  const prompts = [];
3528
- for (let index = 0; index < imageSlots.length; index += 1) {
3529
- const image = imageSlots[index];
3530
- 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;
3531
3593
  if (dryRun || !openRouter) {
3532
3594
  const dryRunStartMs = Date.now();
3533
3595
  prompts.push({
@@ -3554,7 +3616,7 @@ async function expandImagePrompts({
3554
3616
  const response = await openRouter.requestStructured({
3555
3617
  schemaName: "image_prompt",
3556
3618
  schema: imagePromptSchema,
3557
- messages: buildImagePromptMessages(plan, image),
3619
+ messages: buildImagePromptMessages(planContext, image, sectionForImage),
3558
3620
  settings,
3559
3621
  interactionContext: {
3560
3622
  stageId: "image-prompts",
@@ -3770,24 +3832,6 @@ function mergeLlmMetrics(left, right) {
3770
3832
  }
3771
3833
  };
3772
3834
  }
3773
- function buildImageSlots(plan) {
3774
- return [
3775
- {
3776
- id: "cover",
3777
- kind: "cover",
3778
- prompt: "",
3779
- description: plan.coverImageDescription,
3780
- anchorAfterSection: null
3781
- },
3782
- ...plan.inlineImages.map((image, index) => ({
3783
- id: `inline-${index + 1}`,
3784
- kind: "inline",
3785
- prompt: "",
3786
- description: image.description,
3787
- anchorAfterSection: image.anchorAfterSection
3788
- }))
3789
- ];
3790
- }
3791
3835
  function createReplicateInput(settings, prompt, kind) {
3792
3836
  const model = getT2IModel(settings.t2i.modelId);
3793
3837
  const overrides = sanitizeT2IOverrides(settings.t2i.modelId, settings.t2i.inputOverrides);
@@ -4945,7 +4989,9 @@ async function runPipelineShell(input, options = {}) {
4945
4989
  options.onUpdate?.(cloneStages(stages));
4946
4990
  } else {
4947
4991
  imagePrompts = await expandImagePrompts({
4948
- plan,
4992
+ slots: selectImageSlots(plan, text.sections, { maxImages: options.maxImages }),
4993
+ planContext: plan,
4994
+ sections: text.sections,
4949
4995
  settings: input.config.settings,
4950
4996
  openRouter,
4951
4997
  dryRun,
@@ -6310,106 +6356,599 @@ function resolveCustomLinks(existing, addRaw, removeExpressions) {
6310
6356
  return Array.from(result.values());
6311
6357
  }
6312
6358
 
6313
- // src/cli/commands/writeTargetSpecs.ts
6314
- function parseTargetSpec(spec) {
6315
- const trimmed = spec.trim();
6316
- if (!trimmed) {
6317
- 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;
6318
6367
  }
6319
- const [rawType, rawCount] = trimmed.split("=");
6320
- if (!rawType || !rawCount) {
6321
- 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
+ }
6322
6385
  }
6323
- const contentType = rawType.trim();
6324
- if (!contentTypeValues.includes(contentType)) {
6325
- throw new ReportedError(
6326
- `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6327
- );
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
+ }
6328
6401
  }
6329
- const count = Number.parseInt(rawCount.trim(), 10);
6330
- if (!Number.isFinite(count) || count <= 0) {
6331
- 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
+ }
6332
6409
  }
6333
- return {
6334
- contentType,
6335
- count
6336
- };
6410
+ return false;
6337
6411
  }
6338
- function parsePrimaryAndSecondarySpecs(options) {
6339
- const { primarySpec, secondarySpecs } = options;
6340
- if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6341
- 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;
6342
6443
  }
6343
- if (!primarySpec) {
6344
- 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.`);
6345
6447
  }
6346
- const primary = parseTargetSpec(primarySpec);
6347
- if (primary.count !== 1) {
6348
- 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;
6349
6458
  }
6350
- const secondaryDedupedByType = /* @__PURE__ */ new Map();
6351
- for (const spec of secondarySpecs ?? []) {
6352
- const parsed = parseTargetSpec(spec);
6353
- if (parsed.contentType === primary.contentType) {
6354
- throw new ReportedError(
6355
- `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6356
- );
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}`);
6357
6479
  }
6358
- const previous = secondaryDedupedByType.get(parsed.contentType);
6359
- if (previous) {
6360
- previous.count += parsed.count;
6361
- 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;
6362
6499
  }
6363
- secondaryDedupedByType.set(parsed.contentType, {
6364
- ...parsed,
6365
- role: "secondary"
6366
- });
6367
6500
  }
6368
- return [
6369
- {
6370
- ...primary,
6371
- role: "primary"
6372
- },
6373
- ...secondaryDedupedByType.values()
6374
- ];
6501
+ return latestPath;
6375
6502
  }
6376
-
6377
- // src/integrations/mcp/server.ts
6378
- async function startIdeonMcpServer() {
6379
- const server = new McpServer({
6380
- name: "ideon",
6381
- version: package_default.version
6382
- });
6383
- server.registerTool(
6384
- "ideon_write",
6385
- {
6386
- title: "Ideon Write",
6387
- description: "Generate content from an idea using the Ideon pipeline.",
6388
- inputSchema: writeToolInputSchema
6389
- },
6390
- async (input) => {
6391
- try {
6392
- const parsedTargets = parsePrimaryAndSecondarySpecs({
6393
- primarySpec: input.primary,
6394
- secondarySpecs: input.secondary
6395
- });
6396
- const resolved = await resolveRunInput({
6397
- idea: input.idea,
6398
- audience: input.audience,
6399
- jobPath: input.jobPath,
6400
- style: input.style,
6401
- intent: input.intent,
6402
- targetLength: input.length,
6403
- contentTargets: parsedTargets
6404
- });
6405
- const run = await runPipelineShell(resolved, {
6406
- workingDir: cwd(),
6407
- runMode: "fresh",
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
+ });
6943
+ const run = await runPipelineShell(resolved, {
6944
+ workingDir: cwd(),
6945
+ runMode: "fresh",
6408
6946
  dryRun: input.dryRun ?? false,
6409
6947
  enrichLinks: input.enrichLinks ?? false,
6410
6948
  customLinks: input.link,
6411
6949
  unlinks: input.unlink,
6412
- maxLinks: input.maxLinks
6950
+ maxLinks: input.maxLinks,
6951
+ maxImages: input.maxImages
6413
6952
  });
6414
6953
  return {
6415
6954
  content: [
@@ -6468,7 +7007,8 @@ async function startIdeonMcpServer() {
6468
7007
  enrichLinks: input.enrichLinks ?? false,
6469
7008
  customLinks: input.link,
6470
7009
  unlinks: input.unlink,
6471
- maxLinks: input.maxLinks
7010
+ maxLinks: input.maxLinks,
7011
+ maxImages: input.maxImages
6472
7012
  });
6473
7013
  return {
6474
7014
  content: [
@@ -6569,6 +7109,50 @@ async function startIdeonMcpServer() {
6569
7109
  }
6570
7110
  }
6571
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
+ );
6572
7156
  server.registerTool(
6573
7157
  "ideon_config_get",
6574
7158
  {
@@ -6934,476 +7518,156 @@ function applyEdit(action, value2, settings, secrets, setSettings, setSecrets) {
6934
7518
  setSettings({
6935
7519
  ...settings,
6936
7520
  notifications: {
6937
- ...settings.notifications,
6938
- enabled: parsed
6939
- }
6940
- });
6941
- return;
6942
- }
6943
- if (action === "temperature") {
6944
- const nextTemperature = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.temperature), 0, 2);
6945
- setSettings({
6946
- ...settings,
6947
- modelSettings: {
6948
- ...settings.modelSettings,
6949
- temperature: nextTemperature
6950
- }
6951
- });
6952
- return;
6953
- }
6954
- if (action === "maxTokens") {
6955
- const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6956
- setSettings({
6957
- ...settings,
6958
- modelSettings: {
6959
- ...settings.modelSettings,
6960
- maxTokens: nextMaxTokens
7521
+ ...settings.notifications,
7522
+ enabled: parsed
6961
7523
  }
6962
7524
  });
6963
7525
  return;
6964
7526
  }
6965
- if (action === "topP") {
6966
- 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);
6967
7529
  setSettings({
6968
7530
  ...settings,
6969
7531
  modelSettings: {
6970
7532
  ...settings.modelSettings,
6971
- topP: nextTopP
7533
+ temperature: nextTemperature
6972
7534
  }
6973
7535
  });
6974
7536
  return;
6975
7537
  }
6976
- if (action === "markdownOutputDir") {
6977
- setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
6978
- return;
6979
- }
6980
- if (action === "assetOutputDir") {
6981
- setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
6982
- return;
6983
- }
6984
- if (action.startsWith("t2i:")) {
6985
- const fieldName = action.slice(4);
6986
- const parsedValue = coerceT2IFieldValue(settings.t2i.modelId, fieldName, value2);
6987
- const nextOverrides = { ...settings.t2i.inputOverrides };
6988
- if (parsedValue === void 0) {
6989
- delete nextOverrides[fieldName];
6990
- } else {
6991
- nextOverrides[fieldName] = parsedValue;
6992
- }
7538
+ if (action === "maxTokens") {
7539
+ const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6993
7540
  setSettings({
6994
7541
  ...settings,
6995
- t2i: {
6996
- ...settings.t2i,
6997
- inputOverrides: nextOverrides
6998
- }
6999
- });
7000
- }
7001
- }
7002
- function formatEditorValue(value2) {
7003
- if (value2 === null || value2 === void 0) {
7004
- return "";
7005
- }
7006
- return String(value2);
7007
- }
7008
- function formatValue(value2) {
7009
- if (value2 === null || value2 === void 0 || value2 === "") {
7010
- return "(default)";
7011
- }
7012
- return String(value2);
7013
- }
7014
- function parseNumberOrFallback(value2, fallback) {
7015
- const parsed = Number(value2.trim());
7016
- return Number.isFinite(parsed) ? parsed : fallback;
7017
- }
7018
- function clampNumber2(value2, minimum, maximum) {
7019
- return Math.min(maximum, Math.max(minimum, value2));
7020
- }
7021
- function parseBooleanOrFallback(value2, fallback) {
7022
- const normalized = value2.trim().toLowerCase();
7023
- if (normalized === "true") {
7024
- return true;
7025
- }
7026
- if (normalized === "false") {
7027
- return false;
7028
- }
7029
- return fallback;
7030
- }
7031
-
7032
- // src/cli/commands/settings.tsx
7033
- import { jsx as jsx2 } from "react/jsx-runtime";
7034
- async function openSettings() {
7035
- const envSettings = readEnvSettings();
7036
- const [settings, secrets] = await Promise.all([
7037
- loadSavedSettings(),
7038
- loadSecrets({ disableKeytar: envSettings.disableKeytar })
7039
- ]);
7040
- let result = null;
7041
- const app = render(
7042
- /* @__PURE__ */ jsx2(
7043
- SettingsFlow,
7044
- {
7045
- initialSettings: settings,
7046
- initialSecrets: secrets,
7047
- onDone: (value2) => {
7048
- result = value2;
7049
- }
7050
- }
7051
- )
7052
- );
7053
- await app.waitUntilExit();
7054
- const finalResult = result;
7055
- if (!finalResult) {
7056
- console.log("Settings unchanged.");
7057
- return;
7058
- }
7059
- const savedResult = finalResult;
7060
- await saveSettings(savedResult.settings);
7061
- try {
7062
- await saveSecrets(savedResult.secrets, { disableKeytar: envSettings.disableKeytar });
7063
- } catch (error) {
7064
- if (error instanceof KeytarUnavailableError) {
7065
- console.log("Settings saved, but secrets were not stored in the system keychain.");
7066
- console.log("Use IDEON_OPENROUTER_API_KEY and IDEON_REPLICATE_API_TOKEN in this environment.");
7067
- return;
7068
- }
7069
- throw error;
7070
- }
7071
- console.log(`Settings saved to ${getSettingsFilePath()}.`);
7072
- }
7073
-
7074
- // src/cli/commands/serve.ts
7075
- import path12 from "path";
7076
- import { spawn } from "child_process";
7077
-
7078
- // src/server/previewHelpers.ts
7079
- import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
7080
- import path10 from "path";
7081
- var DEFAULT_PORT = 4173;
7082
- var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
7083
- var FILE_PREFIX_TO_CONTENT_TYPE = {
7084
- article: "article",
7085
- blog: "blog-post",
7086
- "x-thread": "x-thread",
7087
- "x-post": "x-post",
7088
- x: "x-post",
7089
- reddit: "reddit-post",
7090
- linkedin: "linkedin-post",
7091
- newsletter: "newsletter"
7092
- };
7093
- var CONTENT_TYPE_LABELS = {
7094
- article: "Article",
7095
- "blog-post": "Blog Post",
7096
- "x-thread": "X Thread",
7097
- "x-post": "X Post",
7098
- "reddit-post": "Reddit Post",
7099
- "linkedin-post": "LinkedIn Post",
7100
- newsletter: "Newsletter"
7101
- };
7102
- function parsePort(portOption) {
7103
- if (!portOption) {
7104
- return DEFAULT_PORT;
7105
- }
7106
- const port = Number.parseInt(portOption, 10);
7107
- if (Number.isNaN(port) || port < 1 || port > 65535) {
7108
- throw new Error(`Invalid port "${portOption}". Choose a value between 1 and 65535.`);
7109
- }
7110
- return port;
7111
- }
7112
- function stripFrontmatter2(markdown) {
7113
- return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
7114
- }
7115
- function extractFrontmatterSlug(markdown) {
7116
- const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
7117
- const frontmatter = frontmatterMatch?.[1];
7118
- if (!frontmatter) {
7119
- return null;
7120
- }
7121
- const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
7122
- const rawSlug = slugMatch?.[1]?.trim();
7123
- if (!rawSlug) {
7124
- return null;
7125
- }
7126
- const unquoted = rawSlug.replace(/^['\"]|['\"]$/g, "").trim();
7127
- return unquoted.length > 0 ? unquoted : null;
7128
- }
7129
- function extractHeadingTitle(markdown) {
7130
- const headingMatch = markdown.match(/^#\s+(.+)$/m);
7131
- if (!headingMatch || !headingMatch[1]) {
7132
- return null;
7133
- }
7134
- return headingMatch[1].trim();
7135
- }
7136
- async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
7137
- if (markdownPathArg) {
7138
- const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
7139
- if (path10.extname(resolved).toLowerCase() !== ".md") {
7140
- throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
7141
- }
7142
- await assertFileExists(resolved, "Could not find markdown file");
7143
- return resolved;
7144
- }
7145
- return await resolveLatestMarkdown(markdownOutputDir);
7146
- }
7147
- async function resolveLatestMarkdown(markdownOutputDir) {
7148
- const markdownCandidates = await findMarkdownFiles(markdownOutputDir);
7149
- if (markdownCandidates.length === 0) {
7150
- throw new Error(
7151
- `No generated articles found in ${markdownOutputDir}. Run ideon write "your idea" first or pass a markdown path.`
7152
- );
7153
- }
7154
- let latestPath = markdownCandidates[0];
7155
- let latestMtime = 0;
7156
- for (const candidate of markdownCandidates) {
7157
- const fileStat = await stat4(candidate);
7158
- if (fileStat.mtimeMs >= latestMtime) {
7159
- latestMtime = fileStat.mtimeMs;
7160
- latestPath = candidate;
7161
- }
7162
- }
7163
- return latestPath;
7164
- }
7165
- async function assertFileExists(filePath, errorPrefix) {
7166
- try {
7167
- const fileStat = await stat4(filePath);
7168
- if (!fileStat.isFile()) {
7169
- throw new Error(`${errorPrefix}: ${filePath}`);
7170
- }
7171
- } catch {
7172
- throw new Error(`${errorPrefix}: ${filePath}`);
7173
- }
7174
- }
7175
- function extractCoverImageUrl(markdown) {
7176
- const body = stripFrontmatter2(markdown);
7177
- const match = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
7178
- return match?.[1] ?? null;
7179
- }
7180
- async function extractArticleMetadata(markdownPath) {
7181
- const markdown = await readFile7(markdownPath, "utf8");
7182
- const fileStat = await stat4(markdownPath);
7183
- const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
7184
- const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
7185
- const body = stripFrontmatter2(markdown);
7186
- const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
7187
- const coverImageUrl = extractCoverImageUrl(markdown);
7188
- return {
7189
- slug,
7190
- title,
7191
- mtime: fileStat.mtimeMs,
7192
- previewSnippet,
7193
- coverImageUrl
7194
- };
7195
- }
7196
- async function listAllGenerations(markdownOutputDir) {
7197
- const markdownFiles = await findMarkdownFiles(markdownOutputDir);
7198
- const grouped = /* @__PURE__ */ new Map();
7199
- for (const filePath of markdownFiles) {
7200
- try {
7201
- const metadata = await extractArticleMetadata(filePath);
7202
- const identity = deriveOutputIdentity(filePath, markdownOutputDir);
7203
- const output = {
7204
- id: `${identity.generationId}:${identity.contentType}:${identity.index}`,
7205
- generationId: identity.generationId,
7206
- sourcePath: filePath,
7207
- slug: metadata.slug,
7208
- title: metadata.title,
7209
- previewSnippet: metadata.previewSnippet,
7210
- coverImageUrl: metadata.coverImageUrl,
7211
- mtime: metadata.mtime,
7212
- contentType: identity.contentType,
7213
- contentTypeLabel: toContentTypeLabel(identity.contentType),
7214
- index: identity.index
7215
- };
7216
- const existing = grouped.get(identity.generationId);
7217
- if (existing) {
7218
- existing.push(output);
7219
- } else {
7220
- grouped.set(identity.generationId, [output]);
7542
+ modelSettings: {
7543
+ ...settings.modelSettings,
7544
+ maxTokens: nextMaxTokens
7221
7545
  }
7222
- } catch {
7223
- }
7546
+ });
7547
+ return;
7224
7548
  }
7225
- const generations = [];
7226
- for (const [id, outputs] of grouped.entries()) {
7227
- outputs.sort((a, b) => compareContentTypes(a.contentType, b.contentType) || a.index - b.index || b.mtime - a.mtime);
7228
- const primaryContentType = await resolvePrimaryContentType(outputs);
7229
- const primary = outputs.find((output) => output.contentType === primaryContentType) ?? outputs[0];
7230
- if (!primary) {
7231
- continue;
7232
- }
7233
- const newestMtime = outputs.reduce((latest, output) => Math.max(latest, output.mtime), 0);
7234
- generations.push({
7235
- id,
7236
- title: primary.title,
7237
- mtime: newestMtime,
7238
- previewSnippet: primary.previewSnippet,
7239
- coverImageUrl: primary.coverImageUrl ?? outputs.find((output) => Boolean(output.coverImageUrl))?.coverImageUrl ?? null,
7240
- primaryContentType,
7241
- 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
+ }
7242
7557
  });
7558
+ return;
7243
7559
  }
7244
- generations.sort((a, b) => b.mtime - a.mtime);
7245
- return generations;
7246
- }
7247
- function deriveGenerationId(markdownPath, markdownOutputDir) {
7248
- const relative = path10.relative(markdownOutputDir, markdownPath);
7249
- const normalized = relative.split(path10.sep).join("/");
7250
- if (!normalized || normalized.startsWith("../")) {
7251
- return path10.basename(markdownPath, ".md");
7560
+ if (action === "markdownOutputDir") {
7561
+ setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
7562
+ return;
7252
7563
  }
7253
- const segments = normalized.split("/").filter(Boolean);
7254
- if (segments.length <= 1) {
7255
- return path10.basename(markdownPath, ".md");
7564
+ if (action === "assetOutputDir") {
7565
+ setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
7566
+ return;
7256
7567
  }
7257
- return segments[0] ?? path10.basename(markdownPath, ".md");
7258
- }
7259
- async function findMarkdownFiles(markdownOutputDir) {
7260
- const files = [];
7261
- const stack = [markdownOutputDir];
7262
- while (stack.length > 0) {
7263
- const current = stack.pop();
7264
- if (!current) {
7265
- continue;
7266
- }
7267
- let entries;
7268
- try {
7269
- entries = await readdir(current, { withFileTypes: true });
7270
- } catch {
7271
- 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;
7272
7576
  }
7273
- for (const entry of entries) {
7274
- const fullPath = path10.join(current, entry.name);
7275
- if (entry.isDirectory()) {
7276
- stack.push(fullPath);
7277
- continue;
7278
- }
7279
- if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
7280
- files.push(fullPath);
7577
+ setSettings({
7578
+ ...settings,
7579
+ t2i: {
7580
+ ...settings.t2i,
7581
+ inputOverrides: nextOverrides
7281
7582
  }
7282
- }
7583
+ });
7283
7584
  }
7284
- return files;
7285
7585
  }
7286
- function deriveOutputIdentity(markdownPath, markdownOutputDir) {
7287
- const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
7288
- const fileBase = path10.basename(markdownPath, ".md");
7289
- const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
7290
- if (!parsed || !parsed[1] || !parsed[2]) {
7291
- return {
7292
- generationId,
7293
- contentType: "article",
7294
- index: 1
7295
- };
7586
+ function formatEditorValue(value2) {
7587
+ if (value2 === null || value2 === void 0) {
7588
+ return "";
7296
7589
  }
7297
- const prefix = parsed[1].toLowerCase();
7298
- const index = Number.parseInt(parsed[2], 10);
7299
- return {
7300
- generationId,
7301
- contentType: FILE_PREFIX_TO_CONTENT_TYPE[prefix] ?? prefix,
7302
- index: Number.isFinite(index) && index > 0 ? index : 1
7303
- };
7590
+ return String(value2);
7304
7591
  }
7305
- function compareContentTypes(left, right) {
7306
- const leftIndex = CONTENT_TYPE_ORDER.indexOf(left);
7307
- const rightIndex = CONTENT_TYPE_ORDER.indexOf(right);
7308
- const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
7309
- const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
7310
- if (normalizedLeft !== normalizedRight) {
7311
- return normalizedLeft - normalizedRight;
7592
+ function formatValue(value2) {
7593
+ if (value2 === null || value2 === void 0 || value2 === "") {
7594
+ return "(default)";
7312
7595
  }
7313
- return left.localeCompare(right);
7596
+ return String(value2);
7314
7597
  }
7315
- function toContentTypeLabel(contentType) {
7316
- const knownLabel = CONTENT_TYPE_LABELS[contentType];
7317
- if (knownLabel) {
7318
- 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;
7319
7609
  }
7320
- return contentType.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
7610
+ if (normalized === "false") {
7611
+ return false;
7612
+ }
7613
+ return fallback;
7321
7614
  }
7322
- async function resolvePrimaryContentType(outputs) {
7323
- const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
7324
- const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
7325
- if (!generationDir) {
7326
- 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;
7327
7642
  }
7328
- const jobPath = path10.join(generationDir, "job.json");
7643
+ const savedResult = finalResult;
7644
+ await saveSettings(savedResult.settings);
7329
7645
  try {
7330
- const raw = await readFile7(jobPath, "utf8");
7331
- const parsed = JSON.parse(raw);
7332
- const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
7333
- const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
7334
- if (primary && typeof primary.contentType === "string") {
7335
- 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;
7336
7652
  }
7337
- } catch {
7338
- return fallback;
7653
+ throw error;
7339
7654
  }
7340
- return fallback;
7655
+ console.log(`Settings saved to ${getSettingsFilePath()}.`);
7341
7656
  }
7342
7657
 
7658
+ // src/cli/commands/serve.ts
7659
+ import path13 from "path";
7660
+ import { spawn } from "child_process";
7661
+
7343
7662
  // src/server/previewServer.ts
7344
7663
  import { execFile } from "child_process";
7345
7664
  import { promisify } from "util";
7346
- import { readFile as readFile8, stat as stat5 } from "fs/promises";
7665
+ import { readFile as readFile9, stat as stat6 } from "fs/promises";
7347
7666
  import { watch as fsWatch } from "fs";
7348
- import path11 from "path";
7667
+ import path12 from "path";
7349
7668
  import { fileURLToPath } from "url";
7350
7669
  import express from "express";
7351
7670
  import { marked } from "marked";
7352
-
7353
- // src/output/enrichMarkdownWithLinks.ts
7354
- function enrichMarkdownWithLinks(markdown, links) {
7355
- if (links.length === 0) {
7356
- return markdown;
7357
- }
7358
- const sorted = [...links].sort((left, right) => right.expression.length - left.expression.length);
7359
- let updated = markdown;
7360
- for (const link of sorted) {
7361
- const escapedExpression = escapeRegExp(link.expression);
7362
- const leadBoundary = /^\w/.test(link.expression) ? "\\b" : "";
7363
- const trailBoundary = /\w$/.test(link.expression) ? "\\b" : "";
7364
- const expressionRegex = new RegExp(`${leadBoundary}${escapedExpression}${trailBoundary}`, "g");
7365
- let match;
7366
- while ((match = expressionRegex.exec(updated)) !== null) {
7367
- const start = match.index;
7368
- const end = start + match[0].length;
7369
- if (isInProtectedSpan(updated, start, end)) {
7370
- continue;
7371
- }
7372
- updated = `${updated.slice(0, start)}[${match[0]}](${link.url})${updated.slice(end)}`;
7373
- break;
7374
- }
7375
- }
7376
- return updated;
7377
- }
7378
- function isInProtectedSpan(content, start, end) {
7379
- const lineStart = content.lastIndexOf("\n", start) + 1;
7380
- const lineEndIdx = content.indexOf("\n", end);
7381
- const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx;
7382
- const line = content.slice(lineStart, lineEnd);
7383
- const linkPattern = /\[[^\]]*\]\([^)]*\)/g;
7384
- let m;
7385
- while ((m = linkPattern.exec(line)) !== null) {
7386
- const absStart = lineStart + m.index;
7387
- const absEnd = absStart + m[0].length;
7388
- if (start >= absStart && end <= absEnd) {
7389
- return true;
7390
- }
7391
- }
7392
- const codePattern = /`[^`]+`/g;
7393
- while ((m = codePattern.exec(line)) !== null) {
7394
- const absStart = lineStart + m.index;
7395
- const absEnd = absStart + m[0].length;
7396
- if (start >= absStart && end <= absEnd) {
7397
- return true;
7398
- }
7399
- }
7400
- return false;
7401
- }
7402
- function escapeRegExp(value2) {
7403
- return value2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7404
- }
7405
-
7406
- // src/server/previewServer.ts
7407
7671
  var execFileAsync = promisify(execFile);
7408
7672
  var MissingArticleError = class extends Error {
7409
7673
  constructor(message) {
@@ -7498,7 +7762,7 @@ async function startPreviewServer(options) {
7498
7762
  if (options.watch) {
7499
7763
  let html2;
7500
7764
  try {
7501
- html2 = await readFile8(path11.join(previewClientDir, "index.html"), "utf8");
7765
+ html2 = await readFile9(path12.join(previewClientDir, "index.html"), "utf8");
7502
7766
  } catch {
7503
7767
  res.status(200).type("html").send(
7504
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>`
@@ -7509,7 +7773,7 @@ async function startPreviewServer(options) {
7509
7773
  const injected = html2.replace("</body>", `${reloadScript}</body>`);
7510
7774
  res.status(200).type("html").send(injected);
7511
7775
  } else {
7512
- res.status(200).sendFile(path11.join(previewClientDir, "index.html"));
7776
+ res.status(200).sendFile(path12.join(previewClientDir, "index.html"));
7513
7777
  }
7514
7778
  return;
7515
7779
  }
@@ -7562,7 +7826,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7562
7826
  generation.outputs.map(async (output) => {
7563
7827
  let markdown = "";
7564
7828
  try {
7565
- markdown = await readFile8(output.sourcePath, "utf8");
7829
+ markdown = await readFile9(output.sourcePath, "utf8");
7566
7830
  } catch (error) {
7567
7831
  if (isMissingFileError(error)) {
7568
7832
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
@@ -7580,7 +7844,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7580
7844
  };
7581
7845
  })
7582
7846
  );
7583
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7847
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7584
7848
  const interactions = generationDir ? await loadSavedInteractions(generationDir) : { llmCalls: [], t2iCalls: [] };
7585
7849
  const analyticsSummary = generationDir ? await loadSavedAnalyticsSummary(generationDir) : null;
7586
7850
  return {
@@ -7609,7 +7873,7 @@ async function resolveActivePreviewArticle(preferredMarkdownPath, markdownOutput
7609
7873
  };
7610
7874
  }
7611
7875
  function resolveGenerationSourcePath(generation, markdownOutputDir) {
7612
- 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);
7613
7877
  }
7614
7878
  function isMissingFileError(error) {
7615
7879
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -7624,7 +7888,7 @@ async function renderArticleHtml(markdown, generationId, sourcePath) {
7624
7888
  async function loadSavedLinks(markdownPath) {
7625
7889
  const linksPath = resolveLinksPath(markdownPath);
7626
7890
  try {
7627
- const raw = await readFile8(linksPath, "utf8");
7891
+ const raw = await readFile9(linksPath, "utf8");
7628
7892
  const parsed = JSON.parse(raw);
7629
7893
  if (!Array.isArray(parsed.links)) {
7630
7894
  return [];
@@ -7648,9 +7912,9 @@ async function loadSavedLinks(markdownPath) {
7648
7912
  }
7649
7913
  }
7650
7914
  async function loadSavedInteractions(generationDir) {
7651
- const interactionsPath = path11.join(generationDir, "model.interactions.json");
7915
+ const interactionsPath = path12.join(generationDir, "model.interactions.json");
7652
7916
  try {
7653
- const raw = await readFile8(interactionsPath, "utf8");
7917
+ const raw = await readFile9(interactionsPath, "utf8");
7654
7918
  const parsed = JSON.parse(raw);
7655
7919
  const llmCalls = Array.isArray(parsed.llmCalls) ? parsed.llmCalls.filter(isPreviewLlmInteraction) : [];
7656
7920
  const t2iCalls = Array.isArray(parsed.t2iCalls) ? parsed.t2iCalls.filter(isPreviewT2IInteraction) : [];
@@ -7666,9 +7930,9 @@ async function loadSavedInteractions(generationDir) {
7666
7930
  }
7667
7931
  }
7668
7932
  async function loadSavedAnalyticsSummary(generationDir) {
7669
- const analyticsPath = path11.join(generationDir, "generation.analytics.json");
7933
+ const analyticsPath = path12.join(generationDir, "generation.analytics.json");
7670
7934
  try {
7671
- const raw = await readFile8(analyticsPath, "utf8");
7935
+ const raw = await readFile9(analyticsPath, "utf8");
7672
7936
  const parsed = JSON.parse(raw);
7673
7937
  const summary = parsed.summary;
7674
7938
  if (!summary || typeof summary !== "object") {
@@ -7700,14 +7964,14 @@ async function getPreviewBootstrapData(preferredMarkdownPath, markdownOutputDir)
7700
7964
  };
7701
7965
  }
7702
7966
  async function resolvePreviewClientBuildDir() {
7703
- const currentDir = path11.dirname(fileURLToPath(import.meta.url));
7967
+ const currentDir = path12.dirname(fileURLToPath(import.meta.url));
7704
7968
  const candidates = [
7705
- path11.resolve(currentDir, "preview"),
7706
- path11.resolve(currentDir, "../../dist/preview")
7969
+ path12.resolve(currentDir, "preview"),
7970
+ path12.resolve(currentDir, "../../dist/preview")
7707
7971
  ];
7708
7972
  for (const candidate of candidates) {
7709
7973
  try {
7710
- const indexStat = await stat5(path11.join(candidate, "index.html"));
7974
+ const indexStat = await stat6(path12.join(candidate, "index.html"));
7711
7975
  if (indexStat.isFile()) {
7712
7976
  return candidate;
7713
7977
  }
@@ -7769,21 +8033,21 @@ async function resolveGenerationAssetPath(generationId, rawAssetPath, markdownOu
7769
8033
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
7770
8034
  }
7771
8035
  const decodedAssetPath = decodeURIComponent(rawAssetPath);
7772
- const normalizedRelative = path11.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7773
- 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)) {
7774
8038
  throw new Error("Invalid generation asset path.");
7775
8039
  }
7776
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
8040
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7777
8041
  if (!generationDir) {
7778
8042
  throw new MissingArticleError(`Generation "${generationId}" has no source directory.`);
7779
8043
  }
7780
- const resolvedPath = path11.resolve(generationDir, normalizedRelative);
7781
- const relativeToGeneration = path11.relative(generationDir, resolvedPath);
7782
- 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)) {
7783
8047
  throw new Error("Invalid generation asset path.");
7784
8048
  }
7785
8049
  try {
7786
- const fileStat = await stat5(resolvedPath);
8050
+ const fileStat = await stat6(resolvedPath);
7787
8051
  if (!fileStat.isFile()) {
7788
8052
  throw new Error("Invalid generation asset path.");
7789
8053
  }
@@ -9263,7 +9527,7 @@ async function runServeCommand(options) {
9263
9527
  const markdownPath = await resolveMarkdownPath(options.markdownPath, outputPaths.markdownOutputDir, process.cwd());
9264
9528
  const port = parsePort(options.port);
9265
9529
  if (options.watch) {
9266
- const viteBin = path12.resolve(process.cwd(), "node_modules", ".bin", "vite");
9530
+ const viteBin = path13.resolve(process.cwd(), "node_modules", ".bin", "vite");
9267
9531
  const viteProcess = spawn(viteBin, ["build", "--watch"], {
9268
9532
  stdio: "inherit",
9269
9533
  shell: process.platform === "win32"
@@ -9289,8 +9553,8 @@ async function runServeCommand(options) {
9289
9553
  openBrowser: options.openBrowser,
9290
9554
  watch: options.watch
9291
9555
  });
9292
- const relativeArticle = path12.relative(process.cwd(), markdownPath);
9293
- 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);
9294
9558
  console.log(`Previewing ${relativeArticle || markdownPath}`);
9295
9559
  console.log(`Serving assets from ${relativeAssets || outputPaths.assetOutputDir}`);
9296
9560
  console.log(`Open ${server.url}`);
@@ -9776,7 +10040,7 @@ function formatPipelineStageCost(stage) {
9776
10040
  }
9777
10041
  return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9778
10042
  }
9779
- async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks) {
10043
+ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages) {
9780
10044
  let previousStages = /* @__PURE__ */ new Map();
9781
10045
  let previousItemStatuses = /* @__PURE__ */ new Map();
9782
10046
  const notificationsEnabled = input.config.settings.notifications.enabled;
@@ -9793,6 +10057,7 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links,
9793
10057
  customLinks: links,
9794
10058
  unlinks,
9795
10059
  maxLinks,
10060
+ maxImages,
9796
10061
  onUpdate(stages) {
9797
10062
  for (const stage of stages) {
9798
10063
  const previous = previousStages.get(stage.id);
@@ -10154,6 +10419,7 @@ function WriteApp({
10154
10419
  links,
10155
10420
  unlinks,
10156
10421
  maxLinks,
10422
+ maxImages,
10157
10423
  onError
10158
10424
  }) {
10159
10425
  const { exit } = useApp3();
@@ -10180,6 +10446,7 @@ function WriteApp({
10180
10446
  customLinks: links,
10181
10447
  unlinks,
10182
10448
  maxLinks,
10449
+ maxImages,
10183
10450
  onUpdate(nextStages) {
10184
10451
  if (mounted) {
10185
10452
  setStages(nextStages);
@@ -10212,7 +10479,7 @@ function WriteApp({
10212
10479
  return () => {
10213
10480
  mounted = false;
10214
10481
  };
10215
- }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, onError, runMode]);
10482
+ }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, maxImages, onError, runMode]);
10216
10483
  useEffect2(() => {
10217
10484
  if (!result && !errorMessage) {
10218
10485
  return;
@@ -10228,7 +10495,7 @@ function WriteApp({
10228
10495
  }
10229
10496
  async function runWriteCommand(options) {
10230
10497
  const input = await resolveInputWithInteractiveIdeaFallback(options);
10231
- 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);
10232
10499
  }
10233
10500
  async function runWriteResumeCommand(options = {}) {
10234
10501
  const session = await loadWriteSession();
@@ -10250,9 +10517,9 @@ async function runWriteResumeCommand(options = {}) {
10250
10517
  secrets: resolved.config.secrets
10251
10518
  }
10252
10519
  };
10253
- 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);
10254
10521
  }
10255
- async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks) {
10522
+ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks, maxImages) {
10256
10523
  let interruptHandled = false;
10257
10524
  const handleSignal = (signal) => {
10258
10525
  if (interruptHandled) {
@@ -10286,7 +10553,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10286
10553
  process.on("SIGTERM", onSigterm);
10287
10554
  try {
10288
10555
  if (noInteractive || !process.stdout.isTTY) {
10289
- await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks);
10556
+ await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages);
10290
10557
  return;
10291
10558
  }
10292
10559
  let commandError = null;
@@ -10301,6 +10568,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10301
10568
  links,
10302
10569
  unlinks,
10303
10570
  maxLinks,
10571
+ maxImages,
10304
10572
  onError: (error) => {
10305
10573
  commandError = error;
10306
10574
  }
@@ -10468,7 +10736,7 @@ function collectOptionValue(value2, previous = []) {
10468
10736
  }
10469
10737
  async function runCli(argv) {
10470
10738
  const program = new Command();
10471
- 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);
10472
10740
  program.command("settings").description("Show the current Ideon settings and storage state.").action(async () => {
10473
10741
  await openSettings();
10474
10742
  });
@@ -10513,6 +10781,14 @@ async function runCli(argv) {
10513
10781
  maxLinks: options.maxLinks
10514
10782
  });
10515
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
+ });
10516
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) => {
10517
10793
  await runServeCommand({
10518
10794
  markdownPath,
@@ -10521,7 +10797,7 @@ async function runCli(argv) {
10521
10797
  watch: options.watch
10522
10798
  });
10523
10799
  });
10524
- 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) => {
10525
10801
  await runWriteCommand({
10526
10802
  idea: options.idea ?? ideaArg,
10527
10803
  audience: options.audience,
@@ -10536,16 +10812,18 @@ async function runCli(argv) {
10536
10812
  enrichLinks: options.enrichLinks,
10537
10813
  links: options.link,
10538
10814
  unlinks: options.unlink,
10539
- maxLinks: options.maxLinks
10815
+ maxLinks: options.maxLinks,
10816
+ maxImages: options.maxImages
10540
10817
  });
10541
10818
  });
10542
- 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) => {
10543
10820
  await runWriteResumeCommand({
10544
10821
  noInteractive: options.noInteractive,
10545
10822
  enrichLinks: options.enrichLinks,
10546
10823
  links: options.link,
10547
10824
  unlinks: options.unlink,
10548
- maxLinks: options.maxLinks
10825
+ maxLinks: options.maxLinks,
10826
+ maxImages: options.maxImages
10549
10827
  });
10550
10828
  });
10551
10829
  await program.parseAsync(argv);