agent-tools.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import { Type, type Static } from "@mariozechner/pi-ai";
  2. import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
  3. import type { PipelineRunner } from "../pipeline/runner.js";
  4. import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
  5. import { readFile, writeFile, readdir, stat } from "node:fs/promises";
  6. import { join, normalize, resolve } from "node:path";
  7. import { StateManager } from "../state/manager.js";
  8. import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
  9. import { writeExportArtifact } from "../interaction/export-artifact.js";
  10. // ---------------------------------------------------------------------------
  11. // Helpers
  12. // ---------------------------------------------------------------------------
  13. function textResult(text: string): AgentToolResult<undefined> {
  14. return { content: [{ type: "text", text }], details: undefined };
  15. }
  16. /**
  17. * Resolve a user-supplied relative path against the books root and guard
  18. * against path-traversal (../ etc.).
  19. */
  20. function safeBooksPath(booksRoot: string, relativePath: string): string {
  21. const resolved = resolve(booksRoot, normalize(relativePath));
  22. if (!resolved.startsWith(booksRoot)) {
  23. throw new Error(`Path traversal blocked: ${relativePath}`);
  24. }
  25. return resolved;
  26. }
  27. function resolveToolBookId(
  28. toolName: string,
  29. paramsBookId: string | undefined,
  30. activeBookId: string | null,
  31. ): string {
  32. const resolvedBookId = paramsBookId ?? activeBookId ?? undefined;
  33. if (!resolvedBookId) {
  34. throw new Error(`${toolName} requires bookId when there is no active book.`);
  35. }
  36. return resolvedBookId;
  37. }
  38. function createDeterministicInteractionTools(pipeline: PipelineRunner, projectRoot: string) {
  39. const state = new StateManager(projectRoot);
  40. return createInteractionToolsFromDeps(pipeline, state);
  41. }
  42. // ---------------------------------------------------------------------------
  43. // 1. SubAgentTool (sub_agent)
  44. // ---------------------------------------------------------------------------
  45. const SubAgentParams = Type.Object({
  46. agent: Type.Union([
  47. Type.Literal("architect"),
  48. Type.Literal("writer"),
  49. Type.Literal("auditor"),
  50. Type.Literal("reviser"),
  51. Type.Literal("exporter"),
  52. ]),
  53. instruction: Type.String({ description: "Natural language instruction for the sub-agent" }),
  54. bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
  55. chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
  56. // -- architect params --
  57. title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
  58. genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
  59. platform: Type.Optional(Type.Union([
  60. Type.Literal("tomato"),
  61. Type.Literal("qidian"),
  62. Type.Literal("feilu"),
  63. Type.Literal("other"),
  64. ], { description: "architect only: target platform. Default: other" })),
  65. language: Type.Optional(Type.Union([
  66. Type.Literal("zh"),
  67. Type.Literal("en"),
  68. ], { description: "architect only: writing language. Default: zh" })),
  69. targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
  70. chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
  71. // -- reviser params --
  72. mode: Type.Optional(Type.Union([
  73. Type.Literal("spot-fix"),
  74. Type.Literal("polish"),
  75. Type.Literal("rewrite"),
  76. Type.Literal("rework"),
  77. Type.Literal("anti-detect"),
  78. ], { description: "reviser only: revision mode. Default: spot-fix" })),
  79. // -- exporter params --
  80. format: Type.Optional(Type.Union([
  81. Type.Literal("txt"),
  82. Type.Literal("md"),
  83. Type.Literal("epub"),
  84. ], { description: "exporter only: export format. Default: txt" })),
  85. approvedOnly: Type.Optional(Type.Boolean({ description: "exporter only: export only approved chapters. Default: false" })),
  86. });
  87. function deriveBookIdFromTitle(title: string): string {
  88. return title
  89. .toLowerCase()
  90. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  91. .replace(/-+/g, "-")
  92. .replace(/^-+|-+$/g, "")
  93. .slice(0, 30);
  94. }
  95. export function createSubAgentTool(
  96. pipeline: PipelineRunner,
  97. activeBookId: string | null,
  98. projectRoot?: string,
  99. ): AgentTool<typeof SubAgentParams> {
  100. return {
  101. name: "sub_agent",
  102. description:
  103. "Delegate a heavy operation to a specialised sub-agent. " +
  104. "Use agent='architect' to initialise a new book, 'writer' to write the next chapter, " +
  105. "'auditor' to audit quality, 'reviser' to revise a chapter, 'exporter' to export.",
  106. label: "Sub-Agent",
  107. parameters: SubAgentParams,
  108. async execute(
  109. _toolCallId: string,
  110. params: Static<typeof SubAgentParams>,
  111. _signal?: AbortSignal,
  112. onUpdate?: AgentToolUpdateCallback,
  113. ): Promise<AgentToolResult<undefined>> {
  114. const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
  115. const progress = (msg: string) => {
  116. onUpdate?.(textResult(msg));
  117. };
  118. try {
  119. switch (agent) {
  120. case "architect": {
  121. if (activeBookId) {
  122. return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
  123. }
  124. const resolvedTitle = title?.trim();
  125. if (!resolvedTitle) {
  126. return textResult('Error: title is required for the architect agent.');
  127. }
  128. const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
  129. const now = new Date().toISOString();
  130. progress(`Starting architect for book "${id}"...`);
  131. await pipeline.initBook(
  132. {
  133. id,
  134. title: resolvedTitle,
  135. genre: genre ?? "general",
  136. platform: (platform ?? "other") as any,
  137. language: (language ?? "zh") as any,
  138. status: "outlining" as any,
  139. targetChapters: targetChapters ?? 200,
  140. chapterWordCount: chapterWordCount ?? 3000,
  141. createdAt: now,
  142. updatedAt: now,
  143. },
  144. { externalContext: instruction },
  145. );
  146. progress(`Architect finished — book "${id}" foundation created.`);
  147. return textResult(`Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`);
  148. }
  149. case "writer": {
  150. if (!bookId) return textResult("Error: bookId is required for the writer agent.");
  151. progress(`Writing next chapter for "${bookId}"...`);
  152. const result = await pipeline.writeNextChapter(bookId, chapterWordCount);
  153. progress(`Writer finished chapter for "${bookId}".`);
  154. return textResult(
  155. `Chapter written for "${bookId}". ` +
  156. `Word count: ${(result as any).wordCount ?? "unknown"}.`,
  157. );
  158. }
  159. case "auditor": {
  160. if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
  161. progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
  162. const audit = await pipeline.auditDraft(bookId, chapterNumber);
  163. progress(`Audit complete for "${bookId}".`);
  164. const issueLines = (audit.issues ?? [])
  165. .map((i: any) => `[${i.severity}] ${i.description}`)
  166. .join("\n");
  167. return textResult(
  168. `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${(audit.issues ?? []).length} issue(s).` +
  169. (issueLines ? `\n${issueLines}` : ""),
  170. );
  171. }
  172. case "reviser": {
  173. if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
  174. const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix";
  175. progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
  176. await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
  177. progress(`Revision complete for "${bookId}".`);
  178. return textResult(`Revision (${resolvedMode}) complete for "${bookId}" chapter ${chapterNumber ?? "latest"}.`);
  179. }
  180. case "exporter": {
  181. if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
  182. if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
  183. const inferredFormat = format ?? (/epub/i.test(instruction)
  184. ? "epub"
  185. : /markdown|\bmd\b/i.test(instruction)
  186. ? "md"
  187. : "txt");
  188. const exportApprovedOnly = approvedOnly ?? /approved|已通过|通过章节/.test(instruction);
  189. const state = new StateManager(projectRoot);
  190. const result = await writeExportArtifact(state, bookId, {
  191. format: inferredFormat,
  192. approvedOnly: exportApprovedOnly,
  193. });
  194. return textResult(
  195. `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
  196. );
  197. }
  198. default:
  199. return textResult(`Unknown agent: ${agent}`);
  200. }
  201. } catch (err: any) {
  202. console.error(`[sub_agent] "${agent}" failed:`, err);
  203. return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
  204. }
  205. },
  206. };
  207. }
  208. // ---------------------------------------------------------------------------
  209. // 2. Deterministic writing tools
  210. // ---------------------------------------------------------------------------
  211. const ReviseChapterParams = Type.Object({
  212. bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
  213. chapterNumber: Type.Number({ description: "Chapter number to revise." }),
  214. mode: Type.Optional(Type.Union([
  215. Type.Literal("spot-fix"),
  216. Type.Literal("polish"),
  217. Type.Literal("rewrite"),
  218. Type.Literal("rework"),
  219. Type.Literal("anti-detect"),
  220. ])),
  221. });
  222. export function createReviseChapterTool(
  223. pipeline: PipelineRunner,
  224. activeBookId: string | null,
  225. ): AgentTool<typeof ReviseChapterParams> {
  226. return {
  227. name: "revise_chapter",
  228. description: "Revise a specific chapter through the deterministic revision pipeline.",
  229. label: "Revise Chapter",
  230. parameters: ReviseChapterParams,
  231. async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
  232. const bookId = resolveToolBookId("revise_chapter", params.bookId, activeBookId);
  233. const mode = params.mode ?? DEFAULT_REVISE_MODE;
  234. await pipeline.reviseDraft(bookId, params.chapterNumber, mode);
  235. return textResult(`Revision (${mode}) complete for "${bookId}" chapter ${params.chapterNumber}.`);
  236. },
  237. };
  238. }
  239. const WriteTruthFileParams = Type.Object({
  240. bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
  241. fileName: Type.String({ description: "Truth file name under story/, e.g. story_bible.md or current_focus.md." }),
  242. content: Type.String({ description: "Full replacement content for the truth file." }),
  243. });
  244. export function createWriteTruthFileTool(
  245. pipeline: PipelineRunner,
  246. projectRoot: string,
  247. activeBookId: string | null,
  248. ): AgentTool<typeof WriteTruthFileParams> {
  249. const tools = createDeterministicInteractionTools(pipeline, projectRoot);
  250. return {
  251. name: "write_truth_file",
  252. description: "Replace a truth/control file under story/ using deterministic project tools.",
  253. label: "Write Truth File",
  254. parameters: WriteTruthFileParams,
  255. async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
  256. const bookId = resolveToolBookId("write_truth_file", params.bookId, activeBookId);
  257. await tools.writeTruthFile(bookId, params.fileName, params.content);
  258. return textResult(`Updated "${params.fileName}" for "${bookId}".`);
  259. },
  260. };
  261. }
  262. const RenameEntityParams = Type.Object({
  263. bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
  264. oldValue: Type.String({ description: "Current entity name." }),
  265. newValue: Type.String({ description: "New entity name." }),
  266. });
  267. export function createRenameEntityTool(
  268. pipeline: PipelineRunner,
  269. projectRoot: string,
  270. activeBookId: string | null,
  271. ): AgentTool<typeof RenameEntityParams> {
  272. const tools = createDeterministicInteractionTools(pipeline, projectRoot);
  273. return {
  274. name: "rename_entity",
  275. description: "Rename an entity across truth files and chapters using deterministic edit control.",
  276. label: "Rename Entity",
  277. parameters: RenameEntityParams,
  278. async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
  279. const bookId = resolveToolBookId("rename_entity", params.bookId, activeBookId);
  280. const result = await tools.renameEntity(bookId, params.oldValue, params.newValue) as {
  281. readonly __interaction?: { readonly responseText?: string };
  282. };
  283. const summary = result.__interaction?.responseText ?? `Renamed "${params.oldValue}" to "${params.newValue}" in "${bookId}".`;
  284. return textResult(summary);
  285. },
  286. };
  287. }
  288. const PatchChapterTextParams = Type.Object({
  289. bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
  290. chapterNumber: Type.Number({ description: "Chapter number to patch." }),
  291. targetText: Type.String({ description: "Exact text to replace." }),
  292. replacementText: Type.String({ description: "Replacement text." }),
  293. });
  294. export function createPatchChapterTextTool(
  295. pipeline: PipelineRunner,
  296. projectRoot: string,
  297. activeBookId: string | null,
  298. ): AgentTool<typeof PatchChapterTextParams> {
  299. const tools = createDeterministicInteractionTools(pipeline, projectRoot);
  300. return {
  301. name: "patch_chapter_text",
  302. description: "Apply a deterministic local text patch to a chapter and mark it for review.",
  303. label: "Patch Chapter",
  304. parameters: PatchChapterTextParams,
  305. async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
  306. const bookId = resolveToolBookId("patch_chapter_text", params.bookId, activeBookId);
  307. const result = await tools.patchChapterText(
  308. bookId,
  309. params.chapterNumber,
  310. params.targetText,
  311. params.replacementText,
  312. ) as {
  313. readonly __interaction?: { readonly responseText?: string };
  314. };
  315. const summary = result.__interaction?.responseText ?? `Patched chapter ${params.chapterNumber} for "${bookId}".`;
  316. return textResult(summary);
  317. },
  318. };
  319. }
  320. // ---------------------------------------------------------------------------
  321. // 3. Read Tool
  322. // ---------------------------------------------------------------------------
  323. const ReadParams = Type.Object({
  324. path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
  325. });
  326. export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
  327. const booksRoot = join(projectRoot, "books");
  328. return {
  329. name: "read",
  330. description: "Read a file from the book directory. Path is relative to books/.",
  331. label: "Read File",
  332. parameters: ReadParams,
  333. async execute(
  334. _toolCallId: string,
  335. params: Static<typeof ReadParams>,
  336. ): Promise<AgentToolResult<undefined>> {
  337. try {
  338. const filePath = safeBooksPath(booksRoot, params.path);
  339. let content = await readFile(filePath, "utf-8");
  340. if (content.length > 10_000) {
  341. content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
  342. }
  343. return textResult(content);
  344. } catch (err: any) {
  345. return textResult(`Failed to read "${params.path}": ${err?.message ?? String(err)}`);
  346. }
  347. },
  348. };
  349. }
  350. // ---------------------------------------------------------------------------
  351. // 3. Edit Tool
  352. // ---------------------------------------------------------------------------
  353. const EditParams = Type.Object({
  354. path: Type.String({ description: "File path relative to books/" }),
  355. old_string: Type.String({ description: "Exact string to find in the file" }),
  356. new_string: Type.String({ description: "Replacement string" }),
  357. });
  358. export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
  359. const booksRoot = join(projectRoot, "books");
  360. return {
  361. name: "edit",
  362. description:
  363. "Edit a file under books/ via exact string replacement. " +
  364. "old_string must appear exactly once in the file. " +
  365. "For chapter text use patch_chapter_text; for canonical truth files (story_bible/volume_outline/book_rules/current_focus) prefer write_truth_file.",
  366. label: "Edit File",
  367. parameters: EditParams,
  368. async execute(
  369. _toolCallId: string,
  370. params: Static<typeof EditParams>,
  371. ): Promise<AgentToolResult<undefined>> {
  372. try {
  373. const filePath = safeBooksPath(booksRoot, params.path);
  374. const content = await readFile(filePath, "utf-8");
  375. const idx = content.indexOf(params.old_string);
  376. if (idx === -1) {
  377. return textResult(`old_string not found in "${params.path}".`);
  378. }
  379. if (content.indexOf(params.old_string, idx + 1) !== -1) {
  380. return textResult(`old_string appears more than once in "${params.path}". Provide a more specific match.`);
  381. }
  382. const updated = content.slice(0, idx) + params.new_string + content.slice(idx + params.old_string.length);
  383. await writeFile(filePath, updated, "utf-8");
  384. return textResult(`File "${params.path}" updated successfully.`);
  385. } catch (err: any) {
  386. return textResult(`Failed to edit "${params.path}": ${err?.message ?? String(err)}`);
  387. }
  388. },
  389. };
  390. }
  391. // ---------------------------------------------------------------------------
  392. // 4. Write Tool
  393. // ---------------------------------------------------------------------------
  394. const WriteFileParams = Type.Object({
  395. path: Type.String({ description: "File path relative to books/" }),
  396. content: Type.String({ description: "Full file content to write" }),
  397. });
  398. export function createWriteFileTool(projectRoot: string): AgentTool<typeof WriteFileParams> {
  399. const booksRoot = join(projectRoot, "books");
  400. return {
  401. name: "write",
  402. description:
  403. "Create a new file, or fully replace an existing file's content under books/. " +
  404. "Parent directories are created automatically. Existing content is overwritten silently — " +
  405. "for canonical truth files prefer write_truth_file; for chapter revisions use revise_chapter.",
  406. label: "Write File",
  407. parameters: WriteFileParams,
  408. async execute(
  409. _toolCallId: string,
  410. params: Static<typeof WriteFileParams>,
  411. ): Promise<AgentToolResult<undefined>> {
  412. try {
  413. const filePath = safeBooksPath(booksRoot, params.path);
  414. const parentDir = resolve(filePath, "..");
  415. const { mkdir } = await import("node:fs/promises");
  416. await mkdir(parentDir, { recursive: true });
  417. await writeFile(filePath, params.content, "utf-8");
  418. return textResult(`File "${params.path}" written successfully.`);
  419. } catch (err: any) {
  420. return textResult(`Failed to write "${params.path}": ${err?.message ?? String(err)}`);
  421. }
  422. },
  423. };
  424. }
  425. // ---------------------------------------------------------------------------
  426. // 5. Grep Tool
  427. // ---------------------------------------------------------------------------
  428. const GrepParams = Type.Object({
  429. bookId: Type.String({ description: "Book ID to search within" }),
  430. pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
  431. });
  432. export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
  433. const booksRoot = join(projectRoot, "books");
  434. return {
  435. name: "grep",
  436. description:
  437. "Search for a text pattern across a book's story/ and chapters/ directories. Returns matching lines.",
  438. label: "Search",
  439. parameters: GrepParams,
  440. async execute(
  441. _toolCallId: string,
  442. params: Static<typeof GrepParams>,
  443. ): Promise<AgentToolResult<undefined>> {
  444. try {
  445. const bookDir = safeBooksPath(booksRoot, params.bookId);
  446. const regex = new RegExp(params.pattern, "gi");
  447. const results: string[] = [];
  448. async function searchDir(dir: string, prefix: string) {
  449. let entries: string[];
  450. try {
  451. entries = await readdir(dir);
  452. } catch {
  453. return; // directory doesn't exist
  454. }
  455. for (const entry of entries) {
  456. const fullPath = join(dir, entry);
  457. const entryStat = await stat(fullPath);
  458. if (entryStat.isDirectory()) {
  459. await searchDir(fullPath, `${prefix}${entry}/`);
  460. } else if (entry.endsWith(".md") || entry.endsWith(".txt") || entry.endsWith(".json")) {
  461. const content = await readFile(fullPath, "utf-8");
  462. const lines = content.split("\n");
  463. for (let i = 0; i < lines.length; i++) {
  464. if (regex.test(lines[i])) {
  465. results.push(`${prefix}${entry}:${i + 1}: ${lines[i]}`);
  466. regex.lastIndex = 0; // reset for next test
  467. }
  468. }
  469. }
  470. }
  471. }
  472. await Promise.all([
  473. searchDir(join(bookDir, "story"), "story/"),
  474. searchDir(join(bookDir, "chapters"), "chapters/"),
  475. ]);
  476. if (results.length === 0) {
  477. return textResult(`No matches for "${params.pattern}" in book "${params.bookId}".`);
  478. }
  479. const truncated = results.length > 100
  480. ? results.slice(0, 100).join("\n") + `\n\n... [${results.length - 100} more matches]`
  481. : results.join("\n");
  482. return textResult(truncated);
  483. } catch (err: any) {
  484. return textResult(`Grep failed: ${err?.message ?? String(err)}`);
  485. }
  486. },
  487. };
  488. }
  489. // ---------------------------------------------------------------------------
  490. // 5. Ls Tool
  491. // ---------------------------------------------------------------------------
  492. const LsParams = Type.Object({
  493. bookId: Type.String({ description: "Book ID" }),
  494. subdir: Type.Optional(
  495. Type.String({ description: "Subdirectory within the book, e.g. 'story', 'chapters', 'story/runtime'" }),
  496. ),
  497. });
  498. export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
  499. const booksRoot = join(projectRoot, "books");
  500. return {
  501. name: "ls",
  502. description: "List files in a book directory. Optionally specify a subdirectory like 'story' or 'chapters'.",
  503. label: "List Files",
  504. parameters: LsParams,
  505. async execute(
  506. _toolCallId: string,
  507. params: Static<typeof LsParams>,
  508. ): Promise<AgentToolResult<undefined>> {
  509. try {
  510. const base = safeBooksPath(booksRoot, params.bookId);
  511. const target = params.subdir ? safeBooksPath(base, params.subdir) : base;
  512. const entries = await readdir(target);
  513. const details: string[] = [];
  514. for (const entry of entries) {
  515. const fullPath = join(target, entry);
  516. try {
  517. const entryStat = await stat(fullPath);
  518. const suffix = entryStat.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
  519. details.push(`${entry}${suffix}`);
  520. } catch {
  521. details.push(entry);
  522. }
  523. }
  524. if (details.length === 0) {
  525. return textResult(`Directory is empty: ${params.bookId}/${params.subdir ?? ""}`);
  526. }
  527. return textResult(details.join("\n"));
  528. } catch (err: any) {
  529. return textResult(`Failed to list "${params.bookId}/${params.subdir ?? ""}": ${err?.message ?? String(err)}`);
  530. }
  531. },
  532. };
  533. }