|
|
@@ -3,10 +3,12 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mario
|
|
|
import type { PipelineRunner } from "../pipeline/runner.js";
|
|
|
import { type ReviseMode } from "../agents/reviser.js";
|
|
|
import { readFile, writeFile, readdir, stat } from "node:fs/promises";
|
|
|
-import { isAbsolute, join, normalize, resolve } from "node:path";
|
|
|
+import { isAbsolute, join, resolve } from "node:path";
|
|
|
import { StateManager } from "../state/manager.js";
|
|
|
-import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
|
|
|
+import { assertSafeTruthFileName, createInteractionToolsFromDeps } from "../interaction/project-tools.js";
|
|
|
import { writeExportArtifact } from "../interaction/export-artifact.js";
|
|
|
+import { assertSafeBookId, deriveBookIdFromTitle } from "../utils/book-id.js";
|
|
|
+import { safeChildPath } from "../utils/path-safety.js";
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Helpers
|
|
|
@@ -23,11 +25,7 @@ function textResult<T = undefined>(text: string, details?: T): AgentToolResult<T
|
|
|
* against path-traversal (../ etc.).
|
|
|
*/
|
|
|
function safeBooksPath(booksRoot: string, relativePath: string): string {
|
|
|
- const resolved = resolve(booksRoot, normalize(relativePath));
|
|
|
- if (!resolved.startsWith(booksRoot)) {
|
|
|
- throw new Error(`Path traversal blocked: ${relativePath}`);
|
|
|
- }
|
|
|
- return resolved;
|
|
|
+ return safeChildPath(booksRoot, relativePath);
|
|
|
}
|
|
|
|
|
|
function resolveToolBookId(
|
|
|
@@ -39,7 +37,11 @@ function resolveToolBookId(
|
|
|
if (!resolvedBookId) {
|
|
|
throw new Error(`${toolName} requires bookId when there is no active book.`);
|
|
|
}
|
|
|
- return resolvedBookId;
|
|
|
+ const safeBookId = assertSafeBookId(resolvedBookId, `${toolName}.bookId`);
|
|
|
+ if (paramsBookId && activeBookId && safeBookId !== activeBookId) {
|
|
|
+ throw new Error(`${toolName}.bookId must match the active book.`);
|
|
|
+ }
|
|
|
+ return safeBookId;
|
|
|
}
|
|
|
|
|
|
function createDeterministicInteractionTools(pipeline: PipelineRunner, projectRoot: string) {
|
|
|
@@ -60,7 +62,9 @@ const SubAgentParams = Type.Object({
|
|
|
Type.Literal("exporter"),
|
|
|
]),
|
|
|
instruction: Type.String({ description: "Natural language instruction for the sub-agent" }),
|
|
|
- bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
|
|
|
+ bookId: Type.Optional(Type.String({
|
|
|
+ description: "Optional book ID. In active-book sessions, omit it to use the current active book; if provided, it must match the current active book. For architect creation, this optionally sets the new book ID.",
|
|
|
+ })),
|
|
|
chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
|
|
|
// -- architect params --
|
|
|
title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
|
|
|
@@ -78,7 +82,7 @@ const SubAgentParams = Type.Object({
|
|
|
targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
|
|
|
chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
|
|
|
revise: Type.Optional(Type.Boolean({
|
|
|
- description: "architect only: true 表示在已有书上重新生成架构稿(比如把旧的条目式格式升级成段落式架构稿 + 一人一卡的角色目录、或者按 feedback 调整某些细节),而不是新建书籍。需要同时提供 bookId",
|
|
|
+ description: "architect only: true 表示在当前 active book 上重新生成架构稿,而不是新建书籍。no-book creation sessions cannot revise an existing book.",
|
|
|
})),
|
|
|
feedback: Type.Optional(Type.String({
|
|
|
description: "architect only: revise 模式下的调整要求。举例:把架构稿从条目式升级成段落式架构稿、某个角色设定需要重新设计、主线冲突表达太弱需要加强等。如果是架构稿评审未通过要求重写的场景,把评审意见的 overallFeedback 原样传入即可",
|
|
|
@@ -100,15 +104,6 @@ const SubAgentParams = Type.Object({
|
|
|
approvedOnly: Type.Optional(Type.Boolean({ description: "exporter only: export only approved chapters. Default: false" })),
|
|
|
});
|
|
|
|
|
|
-function deriveBookIdFromTitle(title: string): string {
|
|
|
- return title
|
|
|
- .toLowerCase()
|
|
|
- .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
|
|
|
- .replace(/-+/g, "-")
|
|
|
- .replace(/^-+|-+$/g, "")
|
|
|
- .slice(0, 30);
|
|
|
-}
|
|
|
-
|
|
|
export function createSubAgentTool(
|
|
|
pipeline: PipelineRunner,
|
|
|
activeBookId: string | null,
|
|
|
@@ -135,27 +130,34 @@ export function createSubAgentTool(
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
+ if (!activeBookId && agent !== "architect") {
|
|
|
+ return textResult("No active book. Only the architect agent can create a book from this session.");
|
|
|
+ }
|
|
|
+ if (activeBookId && agent === "architect" && !revise) {
|
|
|
+ return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
|
|
|
+ }
|
|
|
+
|
|
|
switch (agent) {
|
|
|
case "architect": {
|
|
|
if (revise) {
|
|
|
- if (!bookId) {
|
|
|
- return textResult("Error: architect revise 模式需要 bookId,用于定位要重写的书。");
|
|
|
+ if (!activeBookId) {
|
|
|
+ return textResult("Open the book first before revising its foundation.");
|
|
|
}
|
|
|
- progress(`Revising foundation for "${bookId}"...`);
|
|
|
- await pipeline.reviseFoundation(bookId, feedback ?? instruction);
|
|
|
- progress(`Foundation revised for "${bookId}".`);
|
|
|
+ const targetBookId = resolveToolBookId("architect", bookId, activeBookId);
|
|
|
+ progress(`Revising foundation for "${targetBookId}"...`);
|
|
|
+ await pipeline.reviseFoundation(targetBookId, feedback ?? instruction);
|
|
|
+ progress(`Foundation revised for "${targetBookId}".`);
|
|
|
return textResult(
|
|
|
- `Book "${bookId}" 架构稿已按要求重写。原书的条目式架构稿已备份到 story/.backup-phase4-<时间戳>/。`,
|
|
|
+ `Book "${targetBookId}" 架构稿已按要求重写。原书的条目式架构稿已备份到 story/.backup-phase4-<时间戳>/。`,
|
|
|
);
|
|
|
}
|
|
|
- if (activeBookId) {
|
|
|
- return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
|
|
|
- }
|
|
|
const resolvedTitle = title?.trim();
|
|
|
if (!resolvedTitle) {
|
|
|
return textResult('Error: title is required for the architect agent.');
|
|
|
}
|
|
|
- const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
|
|
|
+ const id = bookId
|
|
|
+ ? assertSafeBookId(bookId, "architect.bookId")
|
|
|
+ : deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
|
|
|
const now = new Date().toISOString();
|
|
|
progress(`Starting architect for book "${id}"...`);
|
|
|
await pipeline.initBook(
|
|
|
@@ -181,21 +183,21 @@ export function createSubAgentTool(
|
|
|
}
|
|
|
|
|
|
case "writer": {
|
|
|
- if (!bookId) return textResult("Error: bookId is required for the writer agent.");
|
|
|
- progress(`Writing next chapter for "${bookId}"...`);
|
|
|
- const result = await pipeline.writeNextChapter(bookId, chapterWordCount);
|
|
|
- progress(`Writer finished chapter for "${bookId}".`);
|
|
|
+ const targetBookId = resolveToolBookId("writer", bookId, activeBookId);
|
|
|
+ progress(`Writing next chapter for "${targetBookId}"...`);
|
|
|
+ const result = await pipeline.writeNextChapter(targetBookId, chapterWordCount);
|
|
|
+ progress(`Writer finished chapter for "${targetBookId}".`);
|
|
|
return textResult(
|
|
|
- `Chapter written for "${bookId}". ` +
|
|
|
+ `Chapter written for "${targetBookId}". ` +
|
|
|
`Word count: ${(result as any).wordCount ?? "unknown"}.`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
case "auditor": {
|
|
|
- if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
|
|
|
- progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
|
|
|
- const audit = await pipeline.auditDraft(bookId, chapterNumber);
|
|
|
- progress(`Audit complete for "${bookId}".`);
|
|
|
+ const targetBookId = resolveToolBookId("auditor", bookId, activeBookId);
|
|
|
+ progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${targetBookId}"...`);
|
|
|
+ const audit = await pipeline.auditDraft(targetBookId, chapterNumber);
|
|
|
+ progress(`Audit complete for "${targetBookId}".`);
|
|
|
const issueLines = (audit.issues ?? [])
|
|
|
.map((i: any) => `[${i.severity}] ${i.description}`)
|
|
|
.join("\n");
|
|
|
@@ -206,16 +208,16 @@ export function createSubAgentTool(
|
|
|
}
|
|
|
|
|
|
case "reviser": {
|
|
|
- if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
|
|
|
+ const targetBookId = resolveToolBookId("reviser", bookId, activeBookId);
|
|
|
const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix";
|
|
|
- progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
|
|
|
- await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
|
|
|
- progress(`Revision complete for "${bookId}".`);
|
|
|
- return textResult(`Revision (${resolvedMode}) complete for "${bookId}" chapter ${chapterNumber ?? "latest"}.`);
|
|
|
+ progress(`Revising "${targetBookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
|
|
|
+ await pipeline.reviseDraft(targetBookId, chapterNumber, resolvedMode);
|
|
|
+ progress(`Revision complete for "${targetBookId}".`);
|
|
|
+ return textResult(`Revision (${resolvedMode}) complete for "${targetBookId}" chapter ${chapterNumber ?? "latest"}.`);
|
|
|
}
|
|
|
|
|
|
case "exporter": {
|
|
|
- if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
|
|
|
+ const targetBookId = resolveToolBookId("exporter", bookId, activeBookId);
|
|
|
if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
|
|
|
const inferredFormat = format ?? (/epub/i.test(instruction)
|
|
|
? "epub"
|
|
|
@@ -224,12 +226,12 @@ export function createSubAgentTool(
|
|
|
: "txt");
|
|
|
const exportApprovedOnly = approvedOnly ?? /approved|已通过|通过章节/.test(instruction);
|
|
|
const state = new StateManager(projectRoot);
|
|
|
- const result = await writeExportArtifact(state, bookId, {
|
|
|
+ const result = await writeExportArtifact(state, targetBookId, {
|
|
|
format: inferredFormat,
|
|
|
approvedOnly: exportApprovedOnly,
|
|
|
});
|
|
|
return textResult(
|
|
|
- `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
|
|
|
+ `Exported "${targetBookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -266,9 +268,14 @@ export function createWriteTruthFileTool(
|
|
|
label: "Write Truth File",
|
|
|
parameters: WriteTruthFileParams,
|
|
|
async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
|
|
|
- const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
|
|
|
- await tools.writeTruthFile(bookId, params.fileName, params.content);
|
|
|
- return textResult(`Updated "${params.fileName}" for "${bookId}".`);
|
|
|
+ try {
|
|
|
+ const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
|
|
|
+ const fileName = assertSafeTruthFileName(params.fileName);
|
|
|
+ await tools.writeTruthFile(bookId, fileName, params.content);
|
|
|
+ return textResult(`Updated "${fileName}" for "${bookId}".`);
|
|
|
+ } catch (err: any) {
|
|
|
+ return textResult(`write_truth_file failed: ${err?.message ?? String(err)}`);
|
|
|
+ }
|
|
|
},
|
|
|
};
|
|
|
}
|