Просмотр исходного кода

feat: add three-layer rule architecture with genre profiles

- GenreProfile and BookRules Zod schemas (genre-profile.ts, book-rules.ts)
- rules-reader.ts: readGenreProfile() with project→builtin fallback
- 5 built-in genre profiles (xuanhuan, xianxia, urban, horror, other)
- js-yaml dependency for YAML frontmatter parsing
- Version bump to 0.3.0
majx_mac 8 месяцев назад
Родитель
Сommit
0746468dbf

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inkos",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "private": true,
   "description": "Multi-agent novel production system",
   "license": "MIT",

+ 1 - 1
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "description": "InkOS CLI — multi-agent novel production system",
   "type": "module",
   "bin": {

+ 51 - 0
packages/core/genres/horror.md

@@ -0,0 +1,51 @@
+---
+name: 恐怖
+id: horror
+chapterTypes: ["氛围章", "事件章", "揭示章", "过渡章", "回收章"]
+fatigueWords: ["毛骨悚然", "不寒而栗", "浑身发冷", "头皮发麻", "鸡皮疙瘩", "心跳加速", "仿佛", "不禁", "宛如", "竟然"]
+numericalSystem: false
+powerScaling: false
+eraResearch: false
+pacingRule: "氛围递进:安全感→微妙不适→确认异常→恐惧升级→高潮→喘息,循环推进"
+satisfactionTypes: ["真相揭示", "成功逃脱", "反杀怪物", "谜团解开", "同伴获救", "规则发现"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- 恐怖源头过早完全暴露(未知才恐怖)
+- 主角无脑刚正面解决一切
+- 用打脸/升级等爽文套路替代恐怖氛围
+- 恐怖元素与日常场景割裂(好的恐怖来自日常的扭曲)
+- 角色面对恐怖事件完全不害怕
+- 用大量血腥描写替代心理恐惧
+
+## 恐惧层级
+
+- 第一层:不适感(微妙的错位、违和)
+- 第二层:不安(确认有异常,但看不清全貌)
+- 第三层:恐惧(威胁明确化,逃生本能启动)
+- 第四层:绝望(规则被打破,安全感彻底崩塌)
+- 不要跳过层级直达高潮,递进才有力量
+
+## 语言铁律
+
+- 恐怖用事实传达,不用情绪标签。✗"他感到一阵恐惧" → ✓"他后颈的汗毛一根根立起来"
+- 禁止过度解释恐怖。异常现象只需呈现,不需叙述者出来总结"这一切都太不正常了"
+- 克制叙事:越恐怖越冷静。句子随恐惧升级而变短,但叙述者语气始终平稳
+- 被淘汰/伤害的配角必须有至少一个暗示其个人故事的细节(书包里的补习班收据、手机壳上的贴纸),让淘汰有重量
+
+## 叙事指导
+
+氛围是第一生产力。用五感细节(声音、气味、温度、触感)建立不安。
+恐怖来自对未知的恐惧,信息揭示要克制。
+"看不见的"永远比"看见的"更可怕。
+
+角色的恐惧反应必须真实:颤抖、口干、思维混乱、判断力下降。
+求生本能驱动行为,不是英雄主义。
+每个安全区都是暂时的,喘息之后是更深的恐惧。
+
+规则感:恐怖世界有自己的规则,发现规则是生存的关键。
+信息管理:读者知道的和角色知道的之间的差距制造悬念。
+日常的扭曲比凭空出现的怪物更恐怖。
+每3章必须打破一次已建立的模式(规则矛盾、可信来源说谎、安全区失效),避免机械重复。

+ 24 - 0
packages/core/genres/other.md

@@ -0,0 +1,24 @@
+---
+name: 通用
+id: other
+chapterTypes: ["推进章", "布局章", "过渡章", "回收章"]
+fatigueWords: ["震惊", "不可思议", "难以置信", "深吸一口气", "仿佛", "不禁", "宛如", "竟然"]
+numericalSystem: false
+powerScaling: false
+eraResearch: false
+pacingRule: "每2-3章有一个明确的进展或反馈"
+satisfactionTypes: ["目标达成", "困难克服", "真相揭示", "关系转变"]
+auditDimensions: [1,2,3,6,7,8,9,10,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- 无逻辑的巧合推进剧情
+- 配角降智配合主角
+- 无铺垫的高潮
+
+## 叙事指导
+
+根据具体题材调整叙事重心。
+保持因果逻辑链完整。
+人物行为由动机驱动,不由剧情需要驱动。

+ 53 - 0
packages/core/genres/urban.md

@@ -0,0 +1,53 @@
+---
+name: 都市
+id: urban
+chapterTypes: ["商战章", "社交章", "布局章", "过渡章", "回收章"]
+fatigueWords: ["冷笑", "不可思议", "震惊", "难以置信", "深吸一口气", "眼中闪过一丝", "仿佛", "不禁", "宛如", "竟然", "核心动机", "信息边界"]
+numericalSystem: false
+powerScaling: false
+eraResearch: true
+pacingRule: "每2-3章一个小回报:商业收益、人脉拓展、对手受挫、信息优势"
+satisfactionTypes: ["商战碾压", "身份揭示", "人脉兑现", "对手打脸", "资源收割", "地位跃升"]
+auditDimensions: [1,2,3,6,7,8,9,10,11,12,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- 无逻辑的商业奇迹(没有铺垫的暴富)
+- 反派降智配合主角表演
+- 无视现实法律和商业规则
+- 用"一个电话搞定"跳过具体操作过程
+- 女性角色沦为花瓶或奖励
+- 混入玄幻/仙侠战力体系
+
+## 年代与现实约束
+
+- 涉及法律、政策、商业规则必须符合设定年代
+- 金融操作、公司运营必须有基本可信度
+- 人物身份、职位、权限不能超出现实合理范围
+- 地名、机构名、行业术语必须准确
+- 物价、收入、生活水平符合时代设定
+
+## 语言铁律
+
+- 人物内心独白必须口语化、直觉化,禁止商业分析/博弈论术语渗入叙事
+- ✗"他迅速分析了当前的债务状况" → ✓"他把那叠皱巴巴的白条翻了三遍"
+- ✗"信息落差就在这儿" → ✓"他们不知道的,他知道"
+- ✗"以这种人的性格,这时候不会撕破脸" → ✓直接写对方的行为反应
+- 法律/商业术语必须匹配设定年代的真实语感:2003年民间借条不会写"逾期处置授权",而是"到期没还,房子归乙方处理"
+- 主角的判断通过行动和对话体现,不通过上帝视角的分析段落
+
+## 叙事指导
+
+以商战、社交博弈和信息差驱动剧情。
+权力来自人脉、资本、信息和制度位置,不来自武力。
+冲突解决靠谈判、交易、威慑、法律手段和利益交换。
+
+钱权必须落地,通过物、势、地位变化和小人物反应兑现爽点。
+人物关系网是核心资产,每次社交互动都应有利益计算。
+主角需要保留非功能性时刻:思考、犹豫、社交润滑。
+主角不是全知全能,必须在前5章内至少出现一次判断失误或信息偏差。
+
+时代厚重感、人情债与制度摩擦是都市文的灵魂。
+用场面、气味、动作、交易、压迫感切入,不要历史课件式开头。
+嵌入1-2个时代锚点(物价、新闻事件、流行用语)增强年代沉浸感。

+ 46 - 0
packages/core/genres/xianxia.md

@@ -0,0 +1,46 @@
+---
+name: 仙侠
+id: xianxia
+chapterTypes: ["战斗章", "悟道章", "布局章", "过渡章", "回收章"]
+fatigueWords: ["冷笑", "蝼蚁", "倒吸凉气", "瞳孔骤缩", "天道", "大道", "因果", "气运", "仿佛", "不禁", "宛如", "竟然"]
+numericalSystem: true
+powerScaling: true
+eraResearch: false
+pacingRule: "修炼/悟道与战斗交替,每3-5章一次小突破或关键收获"
+satisfactionTypes: ["悟道突破", "斗法碾压", "法宝收获", "身份揭示", "天劫渡过", "因果了结"]
+auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- 主角为推剧情突然仁慈、犯蠢
+- 修为无铺垫跳跃式突破
+- 法宝凭空出现解决危机
+- 天道规则前后矛盾
+- 用"大道无形""天道感应"跳过具体修炼过程
+- 同质资源不写衰减默认全额结算
+- 风格混入都市腔、游戏系统播报腔
+
+## 修炼规则
+
+- 境界突破必须有积累过程:悟道、丹药、战斗领悟、机缘
+- 同质资源重复炼化必须写明衰减
+- 法宝体系分品级,使用有代价(灵力、寿元、因果)
+- 金手指/功法四维约束:
+  - 能力上限:有明确的境界/品阶天花板
+  - 附加代价:修炼/使用伴随代价(寿元、因果、心魔)
+  - 触发条件:突破/觉醒需要特定条件(悟道、机缘、天劫)
+  - 成长路径:功法随修为递进,不可跳阶获得
+- 天道规则一旦设定不可违反,除非有明确的特殊机制
+- 期初修为/资源从账本取,增量逐笔列出
+- 跨大境界突破需要天劫或特殊条件
+
+## 叙事指导
+
+修炼与悟道是叙事核心,但必须融入剧情而非独立说教。
+悟道场景用五感描写,不用抽象哲理灌输。
+仙侠世界的规则感要强:因果、天劫、气运都是叙事工具。
+
+人情债与道义约束是仙侠特有的驱动力。
+门派政治、宗门博弈是重要的布局手段。
+战斗以法术、法宝、阵法为核心,注重空间感和规模感。

+ 64 - 0
packages/core/genres/xuanhuan.md

@@ -0,0 +1,64 @@
+---
+name: 玄幻
+id: xuanhuan
+chapterTypes: ["战斗章", "布局章", "过渡章", "回收章"]
+fatigueWords: ["冷笑", "蝼蚁", "倒吸凉气", "瞳孔骤缩", "不可置信", "轰然炸裂", "满场死寂", "难以置信", "仿佛", "不禁", "宛如", "竟然"]
+numericalSystem: true
+powerScaling: true
+eraResearch: false
+pacingRule: "三章内必有明确反馈:打脸、收益兑现、信息反转、地位变化"
+satisfactionTypes: ["打脸", "升级突破", "收益兑现", "智斗碾压", "身份揭示", "底牌亮出"]
+auditDimensions: [1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,17,18,19]
+---
+
+## 题材禁忌
+
+- 主角为推剧情突然仁慈、犯蠢、讲武德
+- 同质资源不写衰减默认全额结算
+- 用"暴涨""海量"跳过数值结算
+- 无铺垫的能力觉醒
+- 反派像木桩一样排队送死
+- 无铺垫强行让退场角色回归
+- 在没有铺垫的情况下突然塞入新体系、新地图、新外挂解决问题
+- 把所有章节都写成高爆裂战斗章
+- 拆解知识库反向污染正文,写成"似曾相识"的拼装文
+- 风格混入都市腔、科幻腔、游戏系统播报腔、轻小说吐槽腔
+
+## 数值规则
+
+- 设定不可吃书:前文确立的设定数值后文不可无升级过程地随意改变
+- 金手指四维约束:
+  - 能力上限:必须设定明确的能力天花板,不可无限升级
+  - 附加代价:使用伴随代价(寿命、体力、副作用),权衡利弊增强冲突
+  - 触发条件:激活与特定场景/事件关联,不可随时随地无条件使用
+  - 成长路径:随主角经历同步升级,解锁过程与剧情节点绑定
+- 同质资源重复吞噬必须写明衰减,不得默认全额结算
+- 同质吞噬衰减公式:收益 = 基础值 × max(0.3, 1 - 0.15×(N-1))
+- 不要用"暴涨""海量""难以估量"跳过数值结算
+- 期初值从账本取(不凭记忆),增量逐笔列出并注明来源
+- 消耗逐笔列出并注明用途,期末 = 期初 + 增量 - 消耗,不得跳步
+- 正文中出现的系统提示(如【气血值+X】)必须与POST_SETTLEMENT一致
+- 若正文写了"比A还高"这类比较句,必须数值验证后再保留
+- 数值连续性必须可追溯:同层级、同类型样本的增量不得无说明跨越一个数量级
+
+## 语言铁律
+
+- 力量体系的量级感用体感传达,不用抽象数字。✗"他的火元从12缕增加到24缕" → ✓"手臂比先前有力了,握拳时指骨发紧"
+- 同一高潮段(如吞火/突破/觉醒)中,同一意象域的渲染不超过两轮,第三轮必须切入新信息或新动作
+- 搜尸/清点/装备段落禁止清单式列举,必须带入角色判断或取舍:✗"他翻出粗盐、水囊、黑面饼" → ✓"水囊最值钱,剩下那点水比命轻不了多少"
+
+## 叙事指导
+
+以战斗和资源获取驱动剧情。主角行为由利益驱动,杀伐果断。
+金手指/能力系统必须有限制:使用频率、范围限制或使用代价。
+设定不可吃书:前文确立的数值后文不可无升级过程地随意改变。
+
+三章内应有明确反馈,但反馈可以是打脸、收益兑现、信息反转、地位变化,不限于杀人。
+涉及吞噬时,收益必须同时落到资源说明与具体增量,不能只写抽象提升。
+小冲突尽快兑现反馈;不要把爽点无限后置。
+
+核心对手必须有脑子,有试探、有误判、有反扑。
+可以留人、钓鱼、示弱、借刀杀人,但前提只能是利益更大,绝不能是心软。
+
+用动作、伤势、声音、重量、冲击、温度来落地"强",少用空泛判断。
+每个场景至少推进一项:信息、地位、资源、伤亡、仇恨、境界。

+ 7 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@actalk/inkos-core",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "description": "InkOS core: models, agents, pipeline, LLM integration",
   "type": "module",
   "main": "dist/index.js",
@@ -11,7 +11,10 @@
       "import": "./dist/index.js"
     }
   },
-  "files": ["dist"],
+  "files": [
+    "dist",
+    "genres"
+  ],
   "license": "MIT",
   "repository": {
     "type": "git",
@@ -25,10 +28,12 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
+    "js-yaml": "^4.1.1",
     "openai": "^4.80.0",
     "zod": "^3.24.0"
   },
   "devDependencies": {
+    "@types/js-yaml": "^4.0.9",
     "typescript": "^5.8.0",
     "vitest": "^3.0.0"
   }

+ 95 - 0
packages/core/src/agents/rules-reader.ts

@@ -0,0 +1,95 @@
+import { readFile, readdir } from "node:fs/promises";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseGenreProfile, type ParsedGenreProfile } from "../models/genre-profile.js";
+import { parseBookRules, type ParsedBookRules } from "../models/book-rules.js";
+
+const BUILTIN_GENRES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../genres");
+
+async function tryReadFile(path: string): Promise<string | null> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Load genre profile. Lookup order:
+ * 1. Project-level: {projectRoot}/genres/{genreId}.md
+ * 2. Built-in:     packages/core/genres/{genreId}.md
+ * 3. Fallback:     built-in other.md
+ */
+export async function readGenreProfile(
+  projectRoot: string,
+  genreId: string,
+): Promise<ParsedGenreProfile> {
+  const projectPath = join(projectRoot, "genres", `${genreId}.md`);
+  const builtinPath = join(BUILTIN_GENRES_DIR, `${genreId}.md`);
+  const fallbackPath = join(BUILTIN_GENRES_DIR, "other.md");
+
+  const raw =
+    (await tryReadFile(projectPath)) ??
+    (await tryReadFile(builtinPath)) ??
+    (await tryReadFile(fallbackPath));
+
+  if (!raw) {
+    throw new Error(`Genre profile not found for "${genreId}" and fallback "other.md" is missing`);
+  }
+
+  return parseGenreProfile(raw);
+}
+
+/**
+ * List all available genre profiles (project-level + built-in, deduped).
+ * Returns array of { id, name, source }.
+ */
+export async function listAvailableGenres(
+  projectRoot: string,
+): Promise<ReadonlyArray<{ readonly id: string; readonly name: string; readonly source: "project" | "builtin" }>> {
+  const results = new Map<string, { id: string; name: string; source: "project" | "builtin" }>();
+
+  // Built-in genres first
+  try {
+    const builtinFiles = await readdir(BUILTIN_GENRES_DIR);
+    for (const file of builtinFiles) {
+      if (!file.endsWith(".md")) continue;
+      const id = file.replace(/\.md$/, "");
+      const raw = await tryReadFile(join(BUILTIN_GENRES_DIR, file));
+      if (!raw) continue;
+      const parsed = parseGenreProfile(raw);
+      results.set(id, { id, name: parsed.profile.name, source: "builtin" });
+    }
+  } catch { /* no builtin dir */ }
+
+  // Project-level genres override
+  const projectDir = join(projectRoot, "genres");
+  try {
+    const projectFiles = await readdir(projectDir);
+    for (const file of projectFiles) {
+      if (!file.endsWith(".md")) continue;
+      const id = file.replace(/\.md$/, "");
+      const raw = await tryReadFile(join(projectDir, file));
+      if (!raw) continue;
+      const parsed = parseGenreProfile(raw);
+      results.set(id, { id, name: parsed.profile.name, source: "project" });
+    }
+  } catch { /* no project genres dir */ }
+
+  return [...results.values()].sort((a, b) => a.id.localeCompare(b.id));
+}
+
+/** Return the path to the built-in genres directory. */
+export function getBuiltinGenresDir(): string {
+  return BUILTIN_GENRES_DIR;
+}
+
+/**
+ * Load book_rules.md from the book's story directory.
+ * Returns null if the file doesn't exist.
+ */
+export async function readBookRules(bookDir: string): Promise<ParsedBookRules | null> {
+  const raw = await tryReadFile(join(bookDir, "story/book_rules.md"));
+  if (!raw) return null;
+  return parseBookRules(raw);
+}

+ 5 - 1
packages/core/src/index.ts

@@ -3,6 +3,8 @@ export { type BookConfig, type Platform, type Genre, type BookStatus, BookConfig
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
 export { type ProjectConfig, type LLMConfig, type NotifyChannel, ProjectConfigSchema, LLMConfigSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
+export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
+export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
 
 // LLM
 export { createLLMClient, chatCompletion, type LLMResponse, type LLMMessage } from "./llm/provider.js";
@@ -12,9 +14,11 @@ export { BaseAgent, type AgentContext } from "./agents/base.js";
 export { ArchitectAgent, type ArchitectOutput } from "./agents/architect.js";
 export { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "./agents/writer.js";
 export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js";
-export { ReviserAgent, type ReviseOutput } from "./agents/reviser.js";
+export { ReviserAgent, type ReviseOutput, type ReviseMode } from "./agents/reviser.js";
 export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js";
 export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js";
+export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js";
+export { buildWriterSystemPrompt } from "./agents/writer-prompts.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";

+ 59 - 0
packages/core/src/models/book-rules.ts

@@ -0,0 +1,59 @@
+import { z } from "zod";
+import yaml from "js-yaml";
+
+const ProtagonistSchema = z.object({
+  name: z.string(),
+  personalityLock: z.array(z.string()).default([]),
+  behavioralConstraints: z.array(z.string()).default([]),
+}).optional();
+
+const GenreLockSchema = z.object({
+  primary: z.string(),
+  forbidden: z.array(z.string()).default([]),
+}).optional();
+
+const NumericalOverridesSchema = z.object({
+  hardCap: z.union([z.number(), z.string()]).optional(),
+  resourceTypes: z.array(z.string()).default([]),
+}).optional();
+
+const EraConstraintsSchema = z.object({
+  enabled: z.boolean().default(false),
+  period: z.string().optional(),
+  region: z.string().optional(),
+}).optional();
+
+export const BookRulesSchema = z.object({
+  version: z.string().default("1.0"),
+  protagonist: ProtagonistSchema,
+  genreLock: GenreLockSchema,
+  numericalSystemOverrides: NumericalOverridesSchema,
+  eraConstraints: EraConstraintsSchema,
+  prohibitions: z.array(z.string()).default([]),
+  chapterTypesOverride: z.array(z.string()).default([]),
+  fatigueWordsOverride: z.array(z.string()).default([]),
+  additionalAuditDimensions: z.array(z.union([z.number(), z.string()])).default([]),
+  enableFullCastTracking: z.boolean().default(false),
+});
+
+export type BookRules = z.infer<typeof BookRulesSchema>;
+
+export interface ParsedBookRules {
+  readonly rules: BookRules;
+  readonly body: string;
+}
+
+export function parseBookRules(raw: string): ParsedBookRules {
+  // Strip markdown code block wrappers if present (LLM often wraps output in ```md ... ```)
+  const stripped = raw.replace(/^```(?:md|markdown|yaml)?\s*\n/, "").replace(/\n```\s*$/, "");
+  const fmMatch = stripped.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
+  if (!fmMatch) {
+    throw new Error("book_rules.md missing YAML frontmatter (--- ... ---)");
+  }
+
+  const frontmatter = yaml.load(fmMatch[1]) as Record<string, unknown>;
+  const rules = BookRulesSchema.parse(frontmatter);
+  const body = fmMatch[2].trim();
+
+  return { rules, body };
+}

+ 35 - 0
packages/core/src/models/genre-profile.ts

@@ -0,0 +1,35 @@
+import { z } from "zod";
+import yaml from "js-yaml";
+
+export const GenreProfileSchema = z.object({
+  name: z.string(),
+  id: z.string(),
+  chapterTypes: z.array(z.string()),
+  fatigueWords: z.array(z.string()),
+  numericalSystem: z.boolean().default(false),
+  powerScaling: z.boolean().default(false),
+  eraResearch: z.boolean().default(false),
+  pacingRule: z.string().default(""),
+  satisfactionTypes: z.array(z.string()).default([]),
+  auditDimensions: z.array(z.number()).default([]),
+});
+
+export type GenreProfile = z.infer<typeof GenreProfileSchema>;
+
+export interface ParsedGenreProfile {
+  readonly profile: GenreProfile;
+  readonly body: string;
+}
+
+export function parseGenreProfile(raw: string): ParsedGenreProfile {
+  const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
+  if (!fmMatch) {
+    throw new Error("Genre profile missing YAML frontmatter (--- ... ---)");
+  }
+
+  const frontmatter = yaml.load(fmMatch[1]) as Record<string, unknown>;
+  const profile = GenreProfileSchema.parse(frontmatter);
+  const body = fmMatch[2].trim();
+
+  return { profile, body };
+}

+ 24 - 0
pnpm-lock.yaml

@@ -32,6 +32,9 @@ importers:
 
   packages/core:
     dependencies:
+      js-yaml:
+        specifier: ^4.1.1
+        version: 4.1.1
       openai:
         specifier: ^4.80.0
         version: 4.104.0(zod@3.25.76)
@@ -39,6 +42,9 @@ importers:
         specifier: ^3.24.0
         version: 3.25.76
     devDependencies:
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
       typescript:
         specifier: ^5.8.0
         version: 5.9.3
@@ -354,6 +360,9 @@ packages:
   '@types/estree@1.0.8':
     resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
 
+  '@types/js-yaml@4.0.9':
+    resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
+
   '@types/node-fetch@2.6.13':
     resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
 
@@ -400,6 +409,9 @@ packages:
     resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
     engines: {node: '>= 8.0.0'}
 
+  argparse@2.0.1:
+    resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
   assertion-error@2.0.1:
     resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
     engines: {node: '>=12'}
@@ -549,6 +561,10 @@ packages:
   js-tokens@9.0.1:
     resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
 
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+    hasBin: true
+
   loupe@3.2.1:
     resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
 
@@ -933,6 +949,8 @@ snapshots:
 
   '@types/estree@1.0.8': {}
 
+  '@types/js-yaml@4.0.9': {}
+
   '@types/node-fetch@2.6.13':
     dependencies:
       '@types/node': 22.19.15
@@ -996,6 +1014,8 @@ snapshots:
     dependencies:
       humanize-ms: 1.2.1
 
+  argparse@2.0.1: {}
+
   assertion-error@2.0.1: {}
 
   asynckit@0.4.0: {}
@@ -1153,6 +1173,10 @@ snapshots:
 
   js-tokens@9.0.1: {}
 
+  js-yaml@4.1.1:
+    dependencies:
+      argparse: 2.0.1
+
   loupe@3.2.1: {}
 
   magic-string@0.30.21: