Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ _notes/
_tmp_*
test-project/
.venv*/

nul
10 changes: 9 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ INKOS_LLM_API_KEY= # API Key
INKOS_LLM_MODEL= # Model name

# Optional
# INKOS_LLM_TEMPERATURE=0.7 # Temperature
# INKOS_LLM_TEMPERATURE=0.7 # Default temperature (Writer auto-tunes 0.6-0.85 by chapter type)
# INKOS_LLM_MAX_TOKENS=8192 # Max output tokens
# INKOS_LLM_THINKING_BUDGET=0 # Anthropic extended thinking budget
```
Expand Down Expand Up @@ -235,6 +235,8 @@ inkos agent "Scan market trends first, then create a new book based on results"
| `inkos import chapters [id] --from <path>` | Import existing chapters for continuation (`--split`, `--resume-from`) |
| `inkos analytics [id]` / `inkos stats [id]` | Book analytics (audit pass rate, top issues, chapter ranking, token usage) |
| `inkos update` | Update to latest version |
| `inkos revise-light [id] [n]` | Lightweight revision (chapter text + instructions only, no truth files). `--context` or `--context-file` |
| `inkos settle [id] [n]` | Post-hoc truth file sync. Reads confirmed chapter and updates state/hooks/ledger |
| `inkos up / down` | Start/stop daemon (`-q` quiet mode, auto-writes `inkos.log`) |

`[id]` is auto-detected when the project has only one book. All commands support `--json` for structured output. `draft`/`write next` support `--context` for writing guidance and `--words` to override per-chapter word count. `book create` supports `--brief <file>` to pass a creative brief (your brainstorming/worldbuilding doc) — the Architect builds from your ideas instead of generating from scratch.
Expand Down Expand Up @@ -320,6 +322,12 @@ TypeScript monorepo managed with pnpm workspaces.
- [x] Stream auto-fallback (auto sync retry when SSE fails — compatible with Zhipu, Gemini proxies, etc.)
- [x] Local model compatibility (fallback parsing + partial response recovery on stream interruption)
- [x] Creative brief (`book create --brief` — pass your brainstorming doc, Architect builds from it)
- [x] Lightweight revision + post-hoc settlement (`revise-light` + `settle`, decouple editing from state sync)
- [x] Dynamic temperature (auto-tunes 0.6–0.85 by chapter type: climax→high, dialogue→low)
- [x] Chapter-aware word count (climax +20%, transition -15%, auto-adjusts per-chapter target)
- [x] Pipeline dry run (zero LLM cost config verification, token estimation, budget decisions)
- [x] Truth file read cache (4 reads → 1 per chapter pipeline, reduced IO)
- [x] Writer prompt compression (~18% token savings, merged methodology + compact tables)
- [ ] `packages/studio` Web UI for review and editing (Vite + React + Hono)
- [ ] Partial chapter intervention (rewrite half a chapter + cascade truth file updates)
- [ ] Full English novel support (English genre profiles, prompts, audit rules, post-write validator)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ INKOS_LLM_API_KEY= # API Key
INKOS_LLM_MODEL= # 模型名

# 可选
# INKOS_LLM_TEMPERATURE=0.7 # 温度
# INKOS_LLM_TEMPERATURE=0.7 # 默认温度(Writer 会按章节类型自动调节 0.6-0.85)
# INKOS_LLM_MAX_TOKENS=8192 # 最大输出 token
# INKOS_LLM_THINKING_BUDGET=0 # Anthropic 扩展思考预算
```
Expand Down Expand Up @@ -235,6 +235,8 @@ inkos agent "先扫描市场趋势,然后根据结果创建一本新书"
| `inkos import chapters [id] --from <path>` | 导入已有章节续写(`--split`、`--resume-from`) |
| `inkos analytics [id]` / `inkos stats [id]` | 书籍数据分析(审计通过率、高频问题、章节排名、token 用量) |
| `inkos update` | 更新到最新版本 |
| `inkos revise-light [id] [n]` | 轻量修订(只用章节文本 + 指令,不加载真相文件)。`--context` 或 `--context-file` 传入修改指令 |
| `inkos settle [id] [n]` | 事后真相文件同步。从已确认章节内容反向更新状态卡/伏笔/账本等 |
| `inkos up / down` | 启动/停止守护进程(`-q` 静默模式,自动写入 `inkos.log`) |

`[id]` 参数在项目只有一本书时可省略,自动检测。所有命令支持 `--json` 输出结构化数据。`draft`/`write next` 支持 `--context` 传入创作指导,`--words` 覆盖每章字数。`book create` 支持 `--brief <file>` 传入创作简报(你的脑洞/设定文档),Architect 会基于此生成设定而非凭空创作。
Expand Down Expand Up @@ -338,6 +340,12 @@ TypeScript 单仓库,pnpm workspaces 管理。
- [x] Stream 自动降级(中转站不支持 SSE 时自动回退 sync,兼容智谱/Gemini 等)
- [x] 本地小模型兼容(fallback 解析 + 流中断部分内容恢复)
- [x] 创作简报(`book create --brief` 传入你的脑洞,基于此生成设定)
- [x] 轻量修订 + 事后结算(`revise-light` + `settle`,拆分修订与状态同步)
- [x] 动态 Temperature(按章节类型自动调节 0.6-0.85,高潮→高温,对话→低温)
- [x] 章节感知 Word Count(高潮章 +20%,过渡章 -15%,自动调节每章目标字数)
- [x] Pipeline Dry Run(零 LLM 消耗验证配置、预估 token 用量、检查预算决策)
- [x] Truth File 读取缓存(同一章管线内 4 次读取 → 1 次,减少 IO)
- [x] Writer Prompt 压缩(~18% 令牌节省,合并方法论 + 紧凑表格)
- [ ] `packages/studio` Web UI 审阅编辑界面(Vite + React + Hono)
- [ ] 局部干预(重写半章 + 级联更新后续 truth 文件)
- [ ] 英文小说全面适配(English genre profiles, prompts, audit rules, post-write validator)
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const draftCommand = new Command("draft")
.option("--context-file <path>", "Read guidance from file")
.option("--json", "Output JSON")
.option("-q, --quiet", "Suppress console output")
.option("--legacy", "Use legacy single-agent pipeline instead of layered 6-step")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const config = await loadConfig();
Expand All @@ -23,7 +24,7 @@ export const draftCommand = new Command("draft")

if (!opts.json) log(`Writing draft for "${bookId}"...`);

const result = await pipeline.writeDraft(bookId, context, wordCount);
const result = await pipeline.writeDraft(bookId, context, wordCount, opts.legacy === true);

if (opts.json) {
log(JSON.stringify(result, null, 2));
Expand Down
144 changes: 144 additions & 0 deletions packages/cli/src/commands/fanfic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Command } from "commander";
import { PipelineRunner } from "@actalk/inkos-core";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";

const FANFIC_MODES = ["canon", "au", "ooc", "cp"] as const;
type FanficMode = typeof FANFIC_MODES[number];

export const fanficCommand = new Command("fanfic")
.description("Fan fiction writing tools — import canon, manage fanfic mode");

fanficCommand
.command("init")
.description("Initialize a fanfic book by importing canon from the parent book")
.argument("[book-id]", "Target fanfic book ID (auto-detected if only one book)")
.requiredOption("--from <parent-book-id>", "Parent book ID to import canon from")
.option("--mode <mode>", "Fanfic mode: canon|au|ooc|cp (default: canon)", "canon")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const root = findProjectRoot();
const bookId = await resolveBookId(bookIdArg, root);
const config = await loadConfig();

const mode = opts.mode as FanficMode;
if (!FANFIC_MODES.includes(mode)) {
throw new Error(`Invalid fanfic mode: ${mode}. Must be one of: ${FANFIC_MODES.join(", ")}`);
}

const pipeline = new PipelineRunner(buildPipelineConfig(config, root));

if (!opts.json) {
log(`Importing fanfic canon from "${opts.from}" into "${bookId}" (mode: ${mode})...`);
}

await pipeline.importFanficCanon(bookId, opts.from, mode);

if (opts.json) {
log(JSON.stringify({
bookId,
parentBookId: opts.from,
mode,
output: "story/fanfic_canon.md",
}, null, 2));
} else {
log(`Fanfic canon imported: story/fanfic_canon.md`);
log(`Mode: ${mode}`);
log(`Writer and auditor will use this file for fanfic-aware writing and review.`);
log(`\nTip: Set fanficMode in book_rules.md frontmatter to enable fanfic audit dimensions.`);
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Fanfic init failed: ${e}`);
}
process.exit(1);
}
});

fanficCommand
.command("show")
.description("Show the current fanfic_canon.md for a book")
.argument("[book-id]", "Book ID (auto-detected if only one book)")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const root = findProjectRoot();
const bookId = await resolveBookId(bookIdArg, root);
const config = await loadConfig();

const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
const canon = await pipeline.showFanficCanon(bookId);

if (!canon) {
if (opts.json) {
log(JSON.stringify({ bookId, canon: null }));
} else {
log(`No fanfic_canon.md found for "${bookId}".`);
log(`Run "inkos fanfic init ${bookId} --from <parent-book-id>" to create one.`);
}
return;
}

if (opts.json) {
log(JSON.stringify({ bookId, canon }, null, 2));
} else {
log(canon);
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Fanfic show failed: ${e}`);
}
process.exit(1);
}
});

fanficCommand
.command("refresh")
.description("Refresh fanfic_canon.md by re-reading parent book (after parent has new chapters)")
.argument("[book-id]", "Target fanfic book ID (auto-detected if only one book)")
.requiredOption("--from <parent-book-id>", "Parent book ID to re-read from")
.option("--mode <mode>", "Fanfic mode: canon|au|ooc|cp (default: canon)", "canon")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const root = findProjectRoot();
const bookId = await resolveBookId(bookIdArg, root);
const config = await loadConfig();

const mode = opts.mode as FanficMode;
if (!FANFIC_MODES.includes(mode)) {
throw new Error(`Invalid fanfic mode: ${mode}. Must be one of: ${FANFIC_MODES.join(", ")}`);
}

const pipeline = new PipelineRunner(buildPipelineConfig(config, root));

if (!opts.json) {
log(`Refreshing fanfic canon from "${opts.from}" for "${bookId}" (mode: ${mode})...`);
}

await pipeline.importFanficCanon(bookId, opts.from, mode);

if (opts.json) {
log(JSON.stringify({
bookId,
parentBookId: opts.from,
mode,
output: "story/fanfic_canon.md",
refreshed: true,
}, null, 2));
} else {
log(`Fanfic canon refreshed: story/fanfic_canon.md`);
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Fanfic refresh failed: ${e}`);
}
process.exit(1);
}
});
60 changes: 60 additions & 0 deletions packages/cli/src/commands/revise-light.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Command } from "commander";
import { PipelineRunner } from "@actalk/inkos-core";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, resolveContext, log, logError } from "../utils.js";

export const reviseLightCommand = new Command("revise-light")
.description("Lightweight revision: only chapter text + instructions, no truth files")
.argument("[book-id]", "Book ID (auto-detected if only one book)")
.argument("[chapter]", "Chapter number (defaults to latest)")
.option("--context <text>", "Revision instructions (inline text)")
.option("--context-file <path>", "Read revision instructions from file")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
try {
const config = await loadConfig();
const root = findProjectRoot();

let bookId: string;
let chapterNumber: number | undefined;
if (bookIdArg && /^\d+$/.test(bookIdArg)) {
bookId = await resolveBookId(undefined, root);
chapterNumber = parseInt(bookIdArg, 10);
} else {
bookId = await resolveBookId(bookIdArg, root);
chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
}

const pipeline = new PipelineRunner(buildPipelineConfig(config, root));

const instructions = await resolveContext(opts);
if (!instructions?.trim()) {
logError("revise-light requires --context or --context-file");
process.exit(1);
}

if (!opts.json) log(`Revise-light "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`);

const result = await pipeline.reviseDraftLight(bookId, chapterNumber, instructions);

if (opts.json) {
log(JSON.stringify(result, null, 2));
} else {
log(` Chapter ${result.chapterNumber} revised (light mode)`);
log(` Words: ${result.wordCount}`);
if (result.fixedIssues.length > 0) {
log(" Fixed:");
for (const fix of result.fixedIssues) {
log(` - ${fix}`);
}
}
log("\n 💡 Run `inkos settle` to sync truth files after confirming the revision.");
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Revise-light failed: ${e}`);
}
process.exit(1);
}
});
7 changes: 5 additions & 2 deletions packages/cli/src/commands/revise.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Command } from "commander";
import { PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, resolveContext, log, logError } from "../utils.js";

export const reviseCommand = new Command("revise")
.description("Revise a chapter based on audit issues")
.argument("[book-id]", "Book ID (auto-detected if only one book)")
.argument("[chapter]", "Chapter number (defaults to latest)")
.option("--mode <mode>", "Revise mode: polish, rewrite, rework, spot-fix", "rewrite")
.option("--context <text>", "Extra revision instructions")
.option("--context-file <path>", "Read extra revision instructions from file")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
try {
Expand All @@ -26,9 +28,10 @@ export const reviseCommand = new Command("revise")
const pipeline = new PipelineRunner(buildPipelineConfig(config, root));

const mode = opts.mode as ReviseMode;
const extraContext = await resolveContext(opts);
if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);

const result = await pipeline.reviseDraft(bookId, chapterNumber, mode);
const result = await pipeline.reviseDraft(bookId, chapterNumber, mode, extraContext);

if (opts.json) {
log(JSON.stringify(result, null, 2));
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/commands/settle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Command } from "commander";
import { PipelineRunner } from "@actalk/inkos-core";
import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";

export const settleCommand = new Command("settle")
.description("Post-hoc state settlement: sync truth files from confirmed chapter content")
.argument("[book-id]", "Book ID (auto-detected if only one book)")
.argument("[chapter]", "Chapter number (defaults to latest)")
.option("--json", "Output JSON")
.action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
try {
const config = await loadConfig();
const root = findProjectRoot();

let bookId: string;
let chapterNumber: number | undefined;
if (bookIdArg && /^\d+$/.test(bookIdArg)) {
bookId = await resolveBookId(undefined, root);
chapterNumber = parseInt(bookIdArg, 10);
} else {
bookId = await resolveBookId(bookIdArg, root);
chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
}

const pipeline = new PipelineRunner(buildPipelineConfig(config, root));

if (!opts.json) log(`Settling "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"}...`);

const result = await pipeline.settleDraft(bookId, chapterNumber);

if (opts.json) {
log(JSON.stringify(result, null, 2));
} else {
log(` Chapter ${result.chapterNumber} settled`);
const s = result.settlement;
const updated: string[] = [];
if (s.updatedState && s.updatedState !== "(状态卡未更新)") updated.push("状态卡");
if (s.updatedHooks && s.updatedHooks !== "(伏笔池未更新)") updated.push("伏笔池");
if (s.updatedLedger && s.updatedLedger !== "(账本未更新)") updated.push("账本");
if (s.chapterSummary) updated.push("章节摘要");
if (s.updatedSubplots) updated.push("支线进度板");
if (s.updatedEmotionalArcs) updated.push("情感弧线");
if (s.updatedCharacterMatrix) updated.push("角色矩阵");
if (updated.length > 0) {
log(` Updated: ${updated.join("、")}`);
}
}
} catch (e) {
if (opts.json) {
log(JSON.stringify({ error: String(e) }));
} else {
logError(`Settle failed: ${e}`);
}
process.exit(1);
}
});
3 changes: 2 additions & 1 deletion packages/cli/src/commands/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ writeCommand
.option("--context-file <path>", "Read guidance from file")
.option("--json", "Output JSON")
.option("-q, --quiet", "Suppress console output")
.option("--legacy", "Use legacy single-agent pipeline instead of layered 6-step")
.action(async (bookIdArg: string | undefined, opts) => {
try {
const config = await loadConfig();
Expand All @@ -34,7 +35,7 @@ writeCommand
for (let i = 0; i < count; i++) {
if (!opts.json) log(`[${i + 1}/${count}] Writing chapter for "${bookId}"...`);

const result = await pipeline.writeNextChapter(bookId, wordCount);
const result = await pipeline.writeNextChapter(bookId, wordCount, undefined, opts.legacy === true);
results.push(result);

if (!opts.json) {
Expand Down
Loading