Browse Source

feat: expose word count control via CLI

- book update: permanently change chapterWordCount, targetChapters, status
- write next / draft / write rewrite: --words flag for per-chapter override
- OpenClaw can dynamically control word count per chapter via --words --json
majx_mac 8 months ago
parent
commit
b017332a6d

+ 5 - 4
README.en.md

@@ -298,12 +298,13 @@ inkos up                          # Daemon mode
 | Command | Description |
 |---------|-------------|
 | `inkos init [name]` | Initialize project (omit name to init current directory) |
-| `inkos book create` | Create a new book (generates worldbuilding + outline + book rules) |
+| `inkos book create` | Create a new book (`--chapter-words` to set word count) |
+| `inkos book update [id]` | Update book settings (`--chapter-words`, `--target-chapters`, `--status`) |
 | `inkos book list` | List all books |
 | `inkos genre list/show/copy/create` | View, copy, or create genres |
-| `inkos write next [id]` | Full pipeline: write next chapter |
+| `inkos write next [id]` | Full pipeline: write next chapter (`--words` to override, `--count` for batch) |
 | `inkos write rewrite [id] <n>` | Rewrite chapter N (restores state snapshot, requires confirmation) |
-| `inkos draft [id]` | Write draft only (no audit or revision) |
+| `inkos draft [id]` | Write draft only (`--words` to override word count) |
 | `inkos audit [id] [n]` | Audit a specific chapter |
 | `inkos revise [id] [n]` | Revise a specific chapter |
 | `inkos agent <instruction>` | Natural language agent mode |
@@ -322,7 +323,7 @@ inkos up                          # Daemon mode
 | `inkos update` | Update to latest version |
 | `inkos up / down` | Start/stop daemon |
 
-`[id]` is auto-detected when the project has only one book. All commands support `--json` for structured output. `draft`/`write next`/`book create` support `--context` for writing guidance.
+`[id]` is auto-detected when the project has only one book. All commands support `--json` for structured output. `draft`/`write next`/`book create` support `--context` for writing guidance and `--words` to override per-chapter word count (OpenClaw can dynamically control this per chapter).
 
 ## Key Features
 

+ 5 - 4
README.md

@@ -298,12 +298,13 @@ inkos up                       # 守护进程模式
 | 命令 | 说明 |
 |------|------|
 | `inkos init [name]` | 初始化项目(省略 name 在当前目录初始化) |
-| `inkos book create` | 创建新书(生成世界观 + 卷纲 + 本书规则) |
+| `inkos book create` | 创建新书(`--chapter-words` 设定字数) |
+| `inkos book update [id]` | 修改书设置(`--chapter-words`、`--target-chapters`、`--status`) |
 | `inkos book list` | 列出所有书籍 |
 | `inkos genre list/show/copy/create` | 查看、复制、创建题材 |
-| `inkos write next [id]` | 完整管线写下一章 |
+| `inkos write next [id]` | 完整管线写下一章(`--words` 覆盖字数,`--count` 连写) |
 | `inkos write rewrite [id] <n>` | 重写第 N 章(恢复状态快照,需确认) |
-| `inkos draft [id]` | 只写草稿(不审不改) |
+| `inkos draft [id]` | 只写草稿(`--words` 覆盖字数) |
 | `inkos audit [id] [n]` | 审计指定章节 |
 | `inkos revise [id] [n]` | 修订指定章节 |
 | `inkos agent <instruction>` | 自然语言 Agent 模式 |
@@ -322,7 +323,7 @@ inkos up                       # 守护进程模式
 | `inkos update` | 更新到最新版本 |
 | `inkos up / down` | 启动/停止守护进程 |
 
-`[id]` 参数在项目只有一本书时可省略,自动检测。所有命令支持 `--json` 输出结构化数据,`draft`/`write next`/`book create` 支持 `--context` 传入创作指导。
+`[id]` 参数在项目只有一本书时可省略,自动检测。所有命令支持 `--json` 输出结构化数据。`draft`/`write next`/`book create` 支持 `--context` 传入创作指导,`--words` 覆盖每章字数(OpenClaw 可逐章动态控制)。
 
 ## 实测数据
 

+ 58 - 1
packages/cli/src/commands/book.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
-import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";
+import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
 
 export const bookCommand = new Command("book")
   .description("Manage books");
@@ -80,6 +80,63 @@ bookCommand
     }
   });
 
+bookCommand
+  .command("update")
+  .description("Update book settings")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--chapter-words <n>", "Words per chapter")
+  .option("--target-chapters <n>", "Target chapter count")
+  .option("--status <status>", "Book status (outlining/active/paused/completed)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+
+      const updates: Record<string, unknown> = {};
+      if (opts.chapterWords) updates.chapterWordCount = parseInt(opts.chapterWords, 10);
+      if (opts.targetChapters) updates.targetChapters = parseInt(opts.targetChapters, 10);
+      if (opts.status) updates.status = opts.status;
+
+      if (Object.keys(updates).length === 0) {
+        if (opts.json) {
+          log(JSON.stringify(book, null, 2));
+        } else {
+          log(`Book: ${book.title} (${bookId})`);
+          log(`  Words/chapter: ${book.chapterWordCount}`);
+          log(`  Target chapters: ${book.targetChapters}`);
+          log(`  Status: ${book.status}`);
+          log(`  Genre: ${book.genre} | Platform: ${book.platform}`);
+        }
+        return;
+      }
+
+      const updated: BookConfig = {
+        ...book,
+        ...updates,
+        updatedAt: new Date().toISOString(),
+      };
+      await state.saveBookConfig(bookId, updated);
+
+      if (opts.json) {
+        log(JSON.stringify(updated, null, 2));
+      } else {
+        for (const [key, value] of Object.entries(updates)) {
+          log(`  ${key}: ${(book as Record<string, unknown>)[key]} → ${value}`);
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to update book: ${e}`);
+      }
+      process.exit(1);
+    }
+  });
+
 bookCommand
   .command("list")
   .description("List all books")

+ 4 - 1
packages/cli/src/commands/draft.ts

@@ -5,6 +5,7 @@ import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookI
 export const draftCommand = new Command("draft")
   .description("Write a draft chapter (no audit/revise)")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--words <n>", "Words per chapter (overrides book config)")
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
@@ -22,9 +23,11 @@ export const draftCommand = new Command("draft")
         projectRoot: root,
       });
 
+      const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
+
       if (!opts.json) log(`Writing draft for "${bookId}"...`);
 
-      const result = await pipeline.writeDraft(bookId, context);
+      const result = await pipeline.writeDraft(bookId, context, wordCount);
 
       if (opts.json) {
         log(JSON.stringify(result, null, 2));

+ 7 - 2
packages/cli/src/commands/write.ts

@@ -13,6 +13,7 @@ writeCommand
   .description("Write the next chapter for a book")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .option("--count <n>", "Number of chapters to write", "1")
+  .option("--words <n>", "Words per chapter (overrides book config)")
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
@@ -33,12 +34,13 @@ writeCommand
       });
 
       const count = parseInt(opts.count, 10);
+      const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 
       const results = [];
       for (let i = 0; i < count; i++) {
         if (!opts.json) log(`[${i + 1}/${count}] Writing chapter for "${bookId}"...`);
 
-        const result = await pipeline.writeNextChapter(bookId);
+        const result = await pipeline.writeNextChapter(bookId, wordCount);
         results.push(result);
 
         if (!opts.json) {
@@ -81,6 +83,7 @@ writeCommand
   .description("Re-generate a specific chapter: rewrite [book-id] <chapter>")
   .argument("<args...>", "Book ID (optional) and chapter number")
   .option("--force", "Skip confirmation prompt")
+  .option("--words <n>", "Words per chapter (overrides book config)")
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
@@ -154,6 +157,8 @@ writeCommand
 
       if (!opts.json) log(`Regenerating chapter ${chapter}...`);
 
+      const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
+
       const pipeline = new PipelineRunner({
         client,
         model: config.llm.model,
@@ -161,7 +166,7 @@ writeCommand
         notifyChannels: config.notify,
       });
 
-      const result = await pipeline.writeNextChapter(bookId);
+      const result = await pipeline.writeNextChapter(bookId, wordCount);
 
       if (opts.json) {
         log(JSON.stringify(result, null, 2));

+ 2 - 1
packages/core/src/agents/writer.ts

@@ -12,6 +12,7 @@ export interface WriteChapterInput {
   readonly bookDir: string;
   readonly chapterNumber: number;
   readonly externalContext?: string;
+  readonly wordCountOverride?: number;
 }
 
 export interface WriteChapterOutput {
@@ -78,7 +79,7 @@ export class WriterAgent extends BaseAgent {
       ledger: genreProfile.numericalSystem ? ledger : "",
       hooks,
       recentChapters,
-      wordCount: book.chapterWordCount,
+      wordCount: input.wordCountOverride ?? book.chapterWordCount,
       externalContext: input.externalContext,
       chapterSummaries,
       subplotBoard,

+ 6 - 4
packages/core/src/pipeline/runner.ts

@@ -118,7 +118,7 @@ export class PipelineRunner {
   }
 
   /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
-  async writeDraft(bookId: string, context?: string): Promise<DraftResult> {
+  async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
       const book = await this.state.loadBookConfig(bookId);
@@ -133,6 +133,7 @@ export class PipelineRunner {
         bookDir,
         chapterNumber,
         externalContext: context ?? this.config.externalContext,
+        ...(wordCount ? { wordCountOverride: wordCount } : {}),
       });
 
       // Save chapter file
@@ -368,16 +369,16 @@ export class PipelineRunner {
   // Full pipeline (convenience — runs draft + audit + revise in one shot)
   // ---------------------------------------------------------------------------
 
-  async writeNextChapter(bookId: string): Promise<ChapterPipelineResult> {
+  async writeNextChapter(bookId: string, wordCount?: number): Promise<ChapterPipelineResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
-      return await this._writeNextChapterLocked(bookId);
+      return await this._writeNextChapterLocked(bookId, wordCount);
     } finally {
       await releaseLock();
     }
   }
 
-  private async _writeNextChapterLocked(bookId: string): Promise<ChapterPipelineResult> {
+  private async _writeNextChapterLocked(bookId: string, wordCount?: number): Promise<ChapterPipelineResult> {
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
@@ -390,6 +391,7 @@ export class PipelineRunner {
       bookDir,
       chapterNumber,
       externalContext: this.config.externalContext,
+      ...(wordCount ? { wordCountOverride: wordCount } : {}),
     });
 
     // 2. Audit chapter