slima-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,46 @@
3
3
  // src/cli/index.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
+ // src/core/schema-compat.ts
7
+ var STRIPPED_SCHEMA_KEYS = ["$schema", "additionalProperties"];
8
+ var STRIPPED = new Set(STRIPPED_SCHEMA_KEYS);
9
+ function stripIncompatibleSchemaKeys(node) {
10
+ if (Array.isArray(node)) {
11
+ return node.map((item) => stripIncompatibleSchemaKeys(item));
12
+ }
13
+ if (node !== null && typeof node === "object") {
14
+ const out = {};
15
+ for (const [key, value] of Object.entries(node)) {
16
+ if (STRIPPED.has(key)) continue;
17
+ out[key] = stripIncompatibleSchemaKeys(value);
18
+ }
19
+ return out;
20
+ }
21
+ return node;
22
+ }
23
+ function sanitizeOutgoingMessage(message) {
24
+ const result = message.result;
25
+ if (!result || !Array.isArray(result.tools)) return message;
26
+ return {
27
+ ...message,
28
+ result: {
29
+ ...result,
30
+ tools: result.tools.map((tool) => {
31
+ const sanitized = { ...tool };
32
+ for (const field of ["inputSchema", "outputSchema"]) {
33
+ if (sanitized[field]) sanitized[field] = stripIncompatibleSchemaKeys(sanitized[field]);
34
+ }
35
+ return sanitized;
36
+ })
37
+ }
38
+ };
39
+ }
40
+ function withGeminiSchemaCompat(transport) {
41
+ const send = transport.send.bind(transport);
42
+ transport.send = (message, options) => send(sanitizeOutgoingMessage(message), options);
43
+ return transport;
44
+ }
45
+
6
46
  // src/cli/server.ts
7
47
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
48
 
@@ -33,7 +73,28 @@ var InsufficientCreditsError = class extends SlimaApiError {
33
73
  this.name = "InsufficientCreditsError";
34
74
  }
35
75
  };
76
+ var RateLimitedError = class extends SlimaApiError {
77
+ constructor(retryAfterSeconds, message) {
78
+ super(429, "RATE_LIMITED", message || "Rate limited");
79
+ this.retryAfterSeconds = retryAfterSeconds;
80
+ this.name = "RateLimitedError";
81
+ }
82
+ };
83
+ var RequestTimeoutError = class extends SlimaApiError {
84
+ constructor(timeoutMs) {
85
+ super(408, "REQUEST_TIMEOUT", `Request timed out after ${Math.round(timeoutMs / 1e3)}s`);
86
+ this.timeoutMs = timeoutMs;
87
+ this.name = "RequestTimeoutError";
88
+ }
89
+ };
36
90
  function formatErrorForMcp(error) {
91
+ if (error instanceof RateLimitedError) {
92
+ const wait = error.retryAfterSeconds;
93
+ return wait ? `Rate limited. Wait ${wait} second${wait === 1 ? "" : "s"} and try the same call again \u2014 this is a temporary throttle, not a rejected request.` : "Rate limited. Wait a few seconds and try the same call again \u2014 this is a temporary throttle, not a rejected request.";
94
+ }
95
+ if (error instanceof RequestTimeoutError) {
96
+ return `${error.message}. The server may still be processing it, so re-running a write could duplicate work \u2014 prefer re-reading the current state first.`;
97
+ }
37
98
  if (error instanceof SlimaApiError) {
38
99
  switch (error.code) {
39
100
  case "UNAUTHORIZED":
@@ -55,6 +116,16 @@ function formatErrorForMcp(error) {
55
116
  }
56
117
 
57
118
  // src/core/api/client.ts
119
+ var REQUEST_TIMEOUT_MS = 6e4;
120
+ function parseRetryAfter(response) {
121
+ const header = response.headers?.get?.("retry-after");
122
+ if (!header) return void 0;
123
+ const seconds = Number(header);
124
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds);
125
+ const when = Date.parse(header);
126
+ if (Number.isNaN(when)) return void 0;
127
+ return Math.max(0, Math.ceil((when - Date.now()) / 1e3));
128
+ }
58
129
  var defaultLogger = {
59
130
  debug: () => {
60
131
  },
@@ -86,25 +157,52 @@ var SlimaApiClient = class {
86
157
  /**
87
158
  * Send API request
88
159
  */
89
- async request(method, path, body) {
160
+ async request(method, path, body, options = {}) {
90
161
  const url = `${this.baseUrl}${path}`;
91
162
  const token = await this.getToken();
92
163
  this.logger.debug(`${method} ${path}`);
93
- const response = await fetch(url, {
94
- method,
95
- headers: {
96
- Authorization: `Bearer ${token}`,
97
- "Content-Type": "application/json",
98
- "User-Agent": `slima-mcp-server/${"0.2.0"}`
99
- },
100
- body: body ? JSON.stringify(body) : void 0
101
- });
164
+ const controller = new AbortController();
165
+ const deadline = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
166
+ let response;
167
+ try {
168
+ response = await fetch(url, {
169
+ method,
170
+ headers: {
171
+ Authorization: `Bearer ${token}`,
172
+ "Content-Type": "application/json",
173
+ "User-Agent": `slima-mcp-server/${"0.3.0"}`
174
+ },
175
+ body: body ? JSON.stringify(body) : void 0,
176
+ signal: controller.signal
177
+ });
178
+ } catch (error) {
179
+ if (controller.signal.aborted || error instanceof Error && error.name === "AbortError") {
180
+ throw new RequestTimeoutError(REQUEST_TIMEOUT_MS);
181
+ }
182
+ throw error;
183
+ } finally {
184
+ clearTimeout(deadline);
185
+ }
102
186
  this.handleUpgradeHint(response);
103
187
  if (!response.ok) {
104
188
  await this.handleErrorResponse(response);
105
189
  }
106
- const json = await response.json();
107
- return json.data;
190
+ const json3 = await response.json();
191
+ return options.envelope ? json3 : json3.data;
192
+ }
193
+ /**
194
+ * Like #request but keeps the whole envelope. Paginated endpoints carry the
195
+ * real total in `meta`, and dropping it is how `list_books` came to report a
196
+ * capped page as if it were the whole library.
197
+ *
198
+ * Delegates to #request rather than re-implementing fetch/auth/error handling:
199
+ * the first version copied those ~20 lines and had already lost request()'s
200
+ * debug log, so list_books was the one call invisible at debug level.
201
+ */
202
+ async requestWithMeta(method, path) {
203
+ return this.request(method, path, void 0, {
204
+ envelope: true
205
+ });
108
206
  }
109
207
  /**
110
208
  * Surface the X-Slima-MCP-Upgrade-Hint header (if present) as a warn log.
@@ -141,20 +239,87 @@ var SlimaApiClient = class {
141
239
  throw new NotFoundError(message);
142
240
  case 402:
143
241
  throw new InsufficientCreditsError();
242
+ case 429:
243
+ throw new RateLimitedError(parseRetryAfter(response), message);
144
244
  default:
145
245
  throw new SlimaApiError(response.status, code, message);
146
246
  }
147
247
  }
148
248
  // === Book Operations ===
149
249
  /**
150
- * List all books
250
+ * List books (one page). `meta` carries the true total — callers must report
251
+ * it rather than presenting a page as the whole library.
151
252
  */
152
- async listBooks() {
153
- return this.request("GET", "/api/v1/books?status=active");
253
+ async listBooks(options = {}) {
254
+ const page = options.page && options.page > 1 ? `&page=${options.page}` : "";
255
+ const response = await this.requestWithMeta(
256
+ "GET",
257
+ `/api/v1/books?status=active${page}`
258
+ );
259
+ return { books: response.data ?? [], meta: response.meta ?? {} };
154
260
  }
155
261
  /**
156
262
  * Get book details
157
263
  */
264
+ // === Teaching surface =====================================================
265
+ //
266
+ // These are why the client stays thin. Everything they return is authored and
267
+ // served by Rails, so the guidance an AI reads updates when the server
268
+ // deploys — including for the users who pinned an old version of this package,
269
+ // who are exactly the ones stale guidance would mislead.
270
+ async getCapabilities() {
271
+ return this.request("GET", "/api/v1/mcp/capabilities");
272
+ }
273
+ async listGuides() {
274
+ return this.request("GET", "/api/v1/mcp/guides");
275
+ }
276
+ async loadGuide(slug) {
277
+ return this.request("GET", `/api/v1/mcp/guides/${encodeURIComponent(slug)}`);
278
+ }
279
+ async listWritingSkills() {
280
+ return this.request("GET", "/api/v1/mcp/skills");
281
+ }
282
+ async loadWritingSkill(key) {
283
+ return this.request("GET", `/api/v1/mcp/skills/${encodeURIComponent(key)}`);
284
+ }
285
+ // === Teams ================================================================
286
+ async listTeams() {
287
+ return this.request("GET", "/api/v1/teams");
288
+ }
289
+ async getTeam(token) {
290
+ return this.request("GET", `/api/v1/teams/${token}`);
291
+ }
292
+ async listTeamSkills(token) {
293
+ return this.request("GET", `/api/v1/teams/${token}/skills`);
294
+ }
295
+ async listTeamMemories(token) {
296
+ return this.request("GET", `/api/v1/teams/${token}/memories`);
297
+ }
298
+ async listTeamRules(token) {
299
+ return this.request("GET", `/api/v1/teams/${token}/rules`);
300
+ }
301
+ async semanticSearch(bookToken, query, limit) {
302
+ return this.request("POST", `/api/v1/books/${bookToken}/semantic-search`, { query, limit });
303
+ }
304
+ // === Notes and comments ===================================================
305
+ async listNotes(bookToken) {
306
+ return this.request("GET", `/api/v1/books/${bookToken}/notes`);
307
+ }
308
+ async createNote(bookToken, note) {
309
+ return this.request("POST", `/api/v1/books/${bookToken}/notes`, { note });
310
+ }
311
+ async listComments(bookToken) {
312
+ return this.request("GET", `/api/v1/books/${bookToken}/comments`);
313
+ }
314
+ async createComment(bookToken, comment) {
315
+ return this.request("POST", `/api/v1/books/${bookToken}/comments`, { comment });
316
+ }
317
+ async resolveComment(bookToken, commentToken) {
318
+ return this.request("POST", `/api/v1/books/${bookToken}/comments/${commentToken}/resolve`);
319
+ }
320
+ async reopenComment(bookToken, commentToken) {
321
+ return this.request("POST", `/api/v1/books/${bookToken}/comments/${commentToken}/reopen`);
322
+ }
158
323
  async getBook(token) {
159
324
  return this.request("GET", `/api/v1/books/${token}`);
160
325
  }
@@ -281,7 +446,8 @@ var SlimaApiClient = class {
281
446
  path: params.path,
282
447
  content: params.content || "",
283
448
  parent_path: params.parentPath,
284
- commit_message: params.commitMessage
449
+ commit_message: params.commitMessage,
450
+ content_type: params.contentType
285
451
  }
286
452
  );
287
453
  }
@@ -299,6 +465,42 @@ var SlimaApiClient = class {
299
465
  }
300
466
  );
301
467
  }
468
+ /**
469
+ * Field-level operations on a structured file — the ONLY write path for
470
+ * `.beats` / `.timeline` / `.script`.
471
+ *
472
+ * One endpoint takes every type; the type comes from the path's extension.
473
+ * The separate tool names on this side exist because a tool that writes must
474
+ * start with a verb, or the desktop agent will not know to ask the human.
475
+ */
476
+ async applyStructuredOps(bookToken, params) {
477
+ return this.request(
478
+ "POST",
479
+ `/api/v1/books/${bookToken}/mcp/files/structured`,
480
+ { path: params.path, intent: params.intent, operations: params.operations }
481
+ );
482
+ }
483
+ /**
484
+ * Search-and-replace inside a file, applied SERVER-side.
485
+ *
486
+ * The old client-side read-modify-write reported a replacement count this
487
+ * process computed and blind-wrote the whole file back, so concurrent edits
488
+ * silently clobbered each other while both callers saw a success
489
+ * (iss_gkqwnovhajzl7xvz). Rails re-reads inside its branch lock.
490
+ */
491
+ async editFile(bookToken, params) {
492
+ return this.request(
493
+ "POST",
494
+ `/api/v1/books/${bookToken}/mcp/files/edit`,
495
+ {
496
+ path: params.path,
497
+ old_string: params.oldString,
498
+ new_string: params.newString,
499
+ replace_all: params.replaceAll,
500
+ commit_message: params.commitMessage
501
+ }
502
+ );
503
+ }
302
504
  /**
303
505
  * Delete file
304
506
  */
@@ -308,7 +510,8 @@ var SlimaApiClient = class {
308
510
  `/api/v1/books/${bookToken}/mcp/files/delete`,
309
511
  {
310
512
  path: params.path,
311
- commit_message: params.commitMessage
513
+ commit_message: params.commitMessage,
514
+ recursive: params.recursive
312
515
  }
313
516
  );
314
517
  }
@@ -326,6 +529,17 @@ var SlimaApiClient = class {
326
529
  }
327
530
  );
328
531
  }
532
+ /**
533
+ * Render a structured file (today: .map relationship graphs) as an image
534
+ * plus a text summary.
535
+ */
536
+ async visualizeFile(bookToken, path) {
537
+ return this.request(
538
+ "POST",
539
+ `/api/v1/books/${bookToken}/mcp/files/visualize`,
540
+ { path }
541
+ );
542
+ }
329
543
  /**
330
544
  * Search file content
331
545
  */
@@ -428,7 +642,9 @@ function formatFileTree(files, indent = 0) {
428
642
 
429
643
  // src/core/tools/books.ts
430
644
  function studioLabel(book) {
431
- return book.bookType === "script" ? "\u{1F4DD} Script Studio" : "\u{1F4D6} Writing Studio";
645
+ if (book.bookType === "script") return "\u{1F4DD} Script Studio";
646
+ if (book.bookType === "knowledge") return "\u{1F4DA} Team Knowledge Base";
647
+ return "\u{1F4D6} Writing Studio";
432
648
  }
433
649
  function scriptStudioWriteRulesBlock(book) {
434
650
  return `### \u{1F512} Write Restrictions (Script Studio)
@@ -488,12 +704,14 @@ You can now add files using create_file with book_token: ${book.token}`
488
704
  );
489
705
  server.tool(
490
706
  "list_books",
491
- "List all books in your Slima library",
492
- {},
707
+ "List the books in your Slima library. Results are paginated; if the response says more books exist, call again with the next `page` before concluding a book is not there.",
708
+ {
709
+ page: z.number().optional().describe("Page number (default 1). Only needed when the response says more books exist.")
710
+ },
493
711
  { readOnlyHint: true, openWorldHint: true },
494
- async () => {
712
+ async ({ page }) => {
495
713
  try {
496
- const books = await client.listBooks();
714
+ const { books, meta } = await client.listBooks({ page });
497
715
  if (books.length === 0) {
498
716
  return {
499
717
  content: [
@@ -509,11 +727,15 @@ You can now add files using create_file with book_token: ${book.token}`
509
727
  return `- **${book.title}** (${book.token}) \u2014 ${studioLabel(book)}
510
728
  Author: ${book.authorName || "N/A"} | Words: ${words}`;
511
729
  }).join("\n\n");
730
+ const currentPage = meta.page ?? 1;
731
+ const totalPages = meta.totalPages;
732
+ const hasMore = totalPages !== void 0 && currentPage < totalPages;
733
+ const header = hasMore ? `Showing ${books.length} of ${meta.total} book(s) \u2014 page ${currentPage} of ${totalPages}. Call list_books again with \`page: ${currentPage + 1}\` for the rest.` : meta.total !== void 0 && meta.total > books.length ? `Showing ${books.length} of ${meta.total} book(s) \u2014 page ${currentPage} of ${totalPages ?? "?"} (this is the last page).` : `Found ${books.length} book(s):`;
512
734
  return {
513
735
  content: [
514
736
  {
515
737
  type: "text",
516
- text: `Found ${books.length} book(s):
738
+ text: `${header}
517
739
 
518
740
  ${formatted}
519
741
 
@@ -550,11 +772,11 @@ _\u{1F4DD} Script Studio books have write restrictions via MCP (see \`get_book\`
550
772
  - **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"}
551
773
  - **Created**: ${book.createdAt}
552
774
  - **Updated**: ${book.updatedAt}`;
553
- const text = book.bookType === "script" ? `${header}
775
+ const text2 = book.bookType === "script" ? `${header}
554
776
 
555
777
  ${scriptStudioWriteRulesBlock(book)}` : header;
556
778
  return {
557
- content: [{ type: "text", text }]
779
+ content: [{ type: "text", text: text2 }]
558
780
  };
559
781
  } catch (error) {
560
782
  return {
@@ -613,35 +835,31 @@ ${tree}${footer}`
613
835
  );
614
836
  server.tool(
615
837
  "get_writing_stats",
616
- "Get writing statistics for a book",
838
+ `Get word-count totals for a book.
839
+
840
+ Reports the book's authoritative totals only. Do NOT compute "progress", "words
841
+ written today", or a writing streak from commit history \u2014 Slima prunes intra-day
842
+ commits after 30 days, so any delta derived from "the last N commits" silently
843
+ changes span over time and will not match what the author sees in the app. Those
844
+ figures are account-level and are not exposed here.`,
617
845
  {
618
846
  book_token: z.string().describe("Book token")
619
847
  },
620
848
  { readOnlyHint: true, openWorldHint: true },
621
849
  async ({ book_token }) => {
622
850
  try {
623
- const [book, commits] = await Promise.all([
624
- client.getBook(book_token),
625
- client.listCommits(book_token, 10)
626
- ]);
627
- let wordChange = 0;
628
- if (commits.length > 1) {
629
- const latest = commits[0].totalWordCount || 0;
630
- const oldest = commits[commits.length - 1].totalWordCount || 0;
631
- wordChange = latest - oldest;
632
- }
633
- const sign = wordChange >= 0 ? "+" : "";
634
- const progressText = commits.length > 1 ? `${sign}${wordChange.toLocaleString()} words (last ${commits.length} commits)` : "N/A (not enough history)";
635
- const text = `# Writing Stats: ${book.title}
851
+ const book = await client.getBook(book_token);
852
+ const text2 = `# Writing Stats: ${book.title}
636
853
 
637
854
  - **Total Words**: ${book.totalWordCount?.toLocaleString() || "0"}
638
- - **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"}
639
- - **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"}
640
- - **Recent Progress**: ${progressText}
641
- - **Commits Analyzed**: ${commits.length}
642
- - **Language**: ${book.language || "N/A"}`;
855
+ - **Manuscript Words**: ${book.manuscriptWordCount?.toLocaleString() || "0"} (the book itself \u2014 what counts toward the author's word count)
856
+ - **Reference Words**: ${book.referenceWordCount?.toLocaleString() || "0"} (notes, worldbuilding \u2014 never counted)
857
+ - **Language**: ${book.language || "N/A"}
858
+ - **Last updated**: ${book.updatedAt}
859
+
860
+ _Daily word count, writing streak and rest protection are account-level in Slima and are not available through this tool. Do not estimate them from commit history \u2014 the figure would not match the author's._`;
643
861
  return {
644
- content: [{ type: "text", text }]
862
+ content: [{ type: "text", text: text2 }]
645
863
  };
646
864
  } catch (error) {
647
865
  return {
@@ -655,94 +873,99 @@ ${tree}${footer}`
655
873
 
656
874
  // src/core/tools/content.ts
657
875
  import { z as z2 } from "zod";
876
+
877
+ // src/core/tools/file-read-rendering.ts
878
+ function renderFileRead(result) {
879
+ if (result.contentWithheld) {
880
+ return [
881
+ `# ${result.file.name} \u{1F512}`,
882
+ "",
883
+ `**Path:** ${result.file.path}`,
884
+ `**Words:** ${result.file.wordCount}`,
885
+ "",
886
+ "---",
887
+ "",
888
+ `The content of this file is withheld from you (reason: ${result.withheldReason ?? "confidential"}).`,
889
+ "It is a confidential team skill: your team allows it to be USED but not READ,",
890
+ "so Slima applies it on the server without showing you the source.",
891
+ "",
892
+ "This is intentional, not an error and not an empty file. Do not try to rewrite,",
893
+ 'restore or "fix" it \u2014 writes to this path are rejected. If you need its contents,',
894
+ "ask a team manager."
895
+ ].join("\n");
896
+ }
897
+ if (result.throttled) {
898
+ return [
899
+ `# ${result.file.name} \u23F3`,
900
+ "",
901
+ `**Path:** ${result.file.path}`,
902
+ `**Words:** ${result.file.wordCount}`,
903
+ "",
904
+ "---",
905
+ "",
906
+ `This team skill's read budget is exhausted for now (${result.throttledReason ?? "daily_read_limit"}).`,
907
+ "The file is not empty \u2014 try again later, or ask a team manager."
908
+ ].join("\n");
909
+ }
910
+ return [
911
+ `# ${result.file.name}`,
912
+ "",
913
+ `**Path:** ${result.file.path}`,
914
+ `**Words:** ${result.file.wordCount}`,
915
+ `**Type:** ${result.file.fileType || "unknown"}`,
916
+ "",
917
+ "---",
918
+ "",
919
+ result.content || "(empty file)"
920
+ ].join("\n");
921
+ }
922
+
923
+ // src/core/tools/content.ts
658
924
  function registerContentTools(server, client) {
659
925
  server.tool(
660
926
  "get_chapter",
661
- "Read the content of a chapter or file from a book",
927
+ "Read a chapter or file from a book. Accepts a full path, or just a filename if you are not sure of the folder. If you already know the exact path, `read_file` is equivalent and more direct.",
662
928
  {
663
929
  book_token: z2.string().describe("Book token (e.g., bk_abc123)"),
664
- file_path: z2.string().describe("File path (e.g., /chapters/01.md or chapter-01)")
930
+ file_path: z2.string().describe("File path (e.g., /chapters/01.md) or just a filename (e.g., 01.md)")
665
931
  },
666
932
  { readOnlyHint: true, openWorldHint: true },
667
933
  async ({ book_token, file_path }) => {
934
+ const normalized = file_path.replace(/^\//, "");
668
935
  try {
669
- const commits = await client.listCommits(book_token, 1);
670
- if (commits.length === 0) {
936
+ const result = await client.readFile(book_token, normalized);
937
+ return { content: [{ type: "text", text: renderFileRead(result) }] };
938
+ } catch (error) {
939
+ if (!(error instanceof NotFoundError)) {
671
940
  return {
672
- content: [
673
- {
674
- type: "text",
675
- text: "No commits found. The book may be empty."
676
- }
677
- ]
941
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
942
+ isError: true
678
943
  };
679
944
  }
680
- const file = findFileInSnapshot(commits[0].filesSnapshot, file_path);
681
- if (!file) {
682
- const availableFiles = listAllFiles(commits[0].filesSnapshot);
683
- const fileList = availableFiles.slice(0, 10).join("\n- ");
684
- const moreText = availableFiles.length > 10 ? `
685
- ... and ${availableFiles.length - 10} more files` : "";
686
- return {
687
- content: [
688
- {
689
- type: "text",
690
- text: `File not found: ${file_path}
945
+ }
946
+ try {
947
+ const { resolvedPath, availablePaths } = await resolveFromStructure(client, book_token, normalized);
948
+ if (!resolvedPath) {
949
+ const available = availablePaths;
950
+ const hint = available.length ? `
691
951
 
692
952
  Available files:
693
- - ${fileList}${moreText}
694
-
695
- Tip: Use get_book_structure to see the full file tree.`
696
- }
697
- ],
698
- isError: true
699
- };
700
- }
701
- if (file.kind === "folder") {
953
+ - ${available.slice(0, 10).join("\n- ")}${available.length > 10 ? `
954
+ ... and ${available.length - 10} more` : ""}` : "";
702
955
  return {
703
956
  content: [
704
957
  {
705
958
  type: "text",
706
- text: `"${file_path}" is a folder, not a file. Please specify a file path.`
707
- }
708
- ],
709
- isError: true
710
- };
711
- }
712
- if (!file.blobHash) {
713
- return {
714
- content: [
715
- {
716
- type: "text",
717
- text: `File "${file_path}" has no content (empty file).`
718
- }
719
- ]
720
- };
721
- }
722
- const blobs = await client.downloadBlobs(book_token, [file.blobHash]);
723
- if (blobs.length === 0) {
724
- return {
725
- content: [
726
- {
727
- type: "text",
728
- text: "Could not retrieve file content. The blob may be missing."
959
+ text: `File not found: ${file_path}${hint}
960
+
961
+ Tip: use get_book_structure to see the full file tree.`
729
962
  }
730
963
  ],
731
964
  isError: true
732
965
  };
733
966
  }
734
- const content = blobs[0].content;
735
- const wordInfo = file.wordCount ? ` (${file.wordCount} words)` : "";
736
- return {
737
- content: [
738
- {
739
- type: "text",
740
- text: `# ${file.name}${wordInfo}
741
-
742
- ${content}`
743
- }
744
- ]
745
- };
967
+ const result = await client.readFile(book_token, resolvedPath);
968
+ return { content: [{ type: "text", text: renderFileRead(result) }] };
746
969
  } catch (error) {
747
970
  return {
748
971
  content: [{ type: "text", text: formatErrorForMcp(error) }],
@@ -752,61 +975,36 @@ ${content}`
752
975
  }
753
976
  );
754
977
  }
755
- function findFileInSnapshot(files, path) {
756
- const normalizedPath = path.replace(/^\//, "").toLowerCase();
757
- const pathParts = normalizedPath.split("/");
758
- function search(items, remainingParts) {
759
- for (const item of items) {
760
- const itemNameLower = item.name.toLowerCase();
761
- const itemTokenLower = item.token.toLowerCase();
762
- if (remainingParts.length === 1) {
763
- const target = remainingParts[0];
764
- if (itemNameLower === target || itemTokenLower === target || itemNameLower.replace(/\.[^.]+$/, "") === target.replace(/\.[^.]+$/, "")) {
765
- return item;
766
- }
767
- }
768
- if (item.kind === "folder" && item.children) {
769
- if (itemNameLower === remainingParts[0] || itemTokenLower === remainingParts[0]) {
770
- const result3 = search(item.children, remainingParts.slice(1));
771
- if (result3) return result3;
772
- }
773
- const result2 = search(item.children, remainingParts);
774
- if (result2) return result2;
775
- }
776
- }
777
- return null;
778
- }
779
- let result = search(files, pathParts);
780
- if (result) return result;
781
- result = searchByName(files, pathParts[pathParts.length - 1]);
782
- return result;
978
+ function fullPathOf(entry, byToken) {
979
+ const parts = [entry.name];
980
+ let current = entry;
981
+ let depth = 0;
982
+ while (current) {
983
+ const parentToken = current.parentToken;
984
+ if (!parentToken) break;
985
+ const parent = byToken.get(parentToken);
986
+ if (!parent || depth++ > byToken.size) break;
987
+ parts.unshift(parent.name);
988
+ current = parent;
989
+ }
990
+ return parts.join("/");
783
991
  }
784
- function searchByName(files, name) {
785
- const nameLower = name.toLowerCase();
786
- for (const item of files) {
787
- const itemNameLower = item.name.toLowerCase();
788
- const itemTokenLower = item.token.toLowerCase();
789
- if (itemNameLower === nameLower || itemTokenLower === nameLower || itemNameLower.replace(/\.[^.]+$/, "") === nameLower.replace(/\.[^.]+$/, "")) {
790
- return item;
791
- }
792
- if (item.kind === "folder" && item.children) {
793
- const result = searchByName(item.children, name);
794
- if (result) return result;
795
- }
796
- }
797
- return null;
992
+ function flatten(files) {
993
+ return files.flatMap((f) => [f, ...f.children ? flatten(f.children) : []]);
798
994
  }
799
- function listAllFiles(files, prefix = "") {
800
- const result = [];
801
- for (const item of files) {
802
- const path = prefix ? `${prefix}/${item.name}` : item.name;
803
- if (item.kind === "file") {
804
- result.push(path);
805
- } else if (item.children) {
806
- result.push(...listAllFiles(item.children, path));
807
- }
808
- }
809
- return result;
995
+ async function resolveFromStructure(client, bookToken, needle) {
996
+ const commits = await client.listCommits(bookToken, 1);
997
+ if (commits.length === 0) return { resolvedPath: null, availablePaths: [] };
998
+ const entries = flatten(commits[0].filesSnapshot ?? []);
999
+ const byToken = new Map(entries.map((e) => [e.token, e]));
1000
+ const wanted = needle.toLowerCase();
1001
+ const stripExt = (value) => value.replace(/\.[^./]+$/, "");
1002
+ const candidates = entries.filter((e) => e.kind !== "folder");
1003
+ const match = candidates.find((e) => fullPathOf(e, byToken).toLowerCase() === wanted) ?? candidates.find((e) => e.name.toLowerCase() === wanted) ?? candidates.find((e) => e.token.toLowerCase() === wanted) ?? candidates.find((e) => fullPathOf(e, byToken).toLowerCase().endsWith(`/${wanted}`)) ?? candidates.find((e) => stripExt(e.name.toLowerCase()) === stripExt(wanted));
1004
+ return {
1005
+ resolvedPath: match ? fullPathOf(match, byToken) : null,
1006
+ availablePaths: candidates.map((e) => fullPathOf(e, byToken))
1007
+ };
810
1008
  }
811
1009
 
812
1010
  // src/core/tools/beta-reader.ts
@@ -912,7 +1110,7 @@ Book "${book.title}" (${book.token}) is a Script Studio book. Please use the Scr
912
1110
  isError: true
913
1111
  };
914
1112
  }
915
- const file = findFileInSnapshot2(commits[0].filesSnapshot, file_path);
1113
+ const file = findFileInSnapshot(commits[0].filesSnapshot, file_path);
916
1114
  if (!file) {
917
1115
  return {
918
1116
  content: [
@@ -977,9 +1175,14 @@ Book "${book.title}" (${book.token}) is a Script Studio book. Please use the Scr
977
1175
  content: [
978
1176
  {
979
1177
  type: "text",
980
- text: `Analysis is still in progress. You can check the status later.
1178
+ text: `The report is still being written (${result.progress ?? 0}% complete). It is running on the server and has already been paid for.
1179
+
1180
+ Call \`get_reader_test\` with this token in a little while to collect it:
981
1181
 
982
- Test ID: ${test.token}`
1182
+ book_token: ${book_token}
1183
+ test_token: ${test.token}
1184
+
1185
+ Do NOT call analyze_chapter again for this chapter \u2014 that starts a separate report and charges for it a second time.`
983
1186
  }
984
1187
  ]
985
1188
  };
@@ -997,19 +1200,73 @@ Test ID: ${test.token}`
997
1200
  }
998
1201
  }
999
1202
  );
1203
+ server.tool(
1204
+ "get_reader_test",
1205
+ `Collect an AI Beta Reader report by its test token \u2014 for a report that analyze_chapter started but did not finish inside its wait window.
1206
+
1207
+ Use this rather than calling analyze_chapter again: the report is already running server-side and has already been charged. Re-running starts a second report and charges again.`,
1208
+ {
1209
+ book_token: z3.string().describe("Book token (e.g., bk_abc123)"),
1210
+ test_token: z3.string().describe("Reader test token, as handed back by analyze_chapter when it timed out")
1211
+ },
1212
+ { readOnlyHint: true, openWorldHint: true },
1213
+ async ({ book_token, test_token }) => {
1214
+ try {
1215
+ const progress = await client.getReaderTestProgress(book_token, test_token);
1216
+ if (progress.status === "failed") {
1217
+ return {
1218
+ content: [
1219
+ {
1220
+ type: "text",
1221
+ text: "That report failed while running. Start a new one with analyze_chapter."
1222
+ }
1223
+ ],
1224
+ isError: true
1225
+ };
1226
+ }
1227
+ if (progress.status !== "completed") {
1228
+ return {
1229
+ content: [
1230
+ {
1231
+ type: "text",
1232
+ text: `Still being written (${progress.progress ?? 0}% complete). Call get_reader_test again with the same token shortly \u2014 do not start a new analysis, this one is still running and already paid for.`
1233
+ }
1234
+ ]
1235
+ };
1236
+ }
1237
+ const fullResult = await client.getReaderTest(book_token, test_token);
1238
+ return {
1239
+ content: [
1240
+ { type: "text", text: formatBetaReaderFeedback(fullResult, describeScope(fullResult)) }
1241
+ ]
1242
+ };
1243
+ } catch (error) {
1244
+ return {
1245
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1246
+ isError: true
1247
+ };
1248
+ }
1249
+ }
1250
+ );
1251
+ }
1252
+ function describeScope(test) {
1253
+ const scopePath = test.scopeConfig?.filePath;
1254
+ return typeof scopePath === "string" && scopePath.length > 0 ? scopePath : `${test.reportType} report`;
1000
1255
  }
1001
1256
  async function pollForCompletion(checkFn, timeoutMs, logger2) {
1002
1257
  const startTime = Date.now();
1003
1258
  const pollInterval = 3e3;
1259
+ let lastProgress;
1004
1260
  while (Date.now() - startTime < timeoutMs) {
1005
1261
  const result = await checkFn();
1262
+ lastProgress = result.progress;
1006
1263
  logger2.debug(`Poll status: ${result.status}, progress: ${result.progress || "N/A"}`);
1007
1264
  if (result.status === "completed" || result.status === "failed") {
1008
1265
  return result;
1009
1266
  }
1010
1267
  await sleep(pollInterval);
1011
1268
  }
1012
- return { status: "timeout" };
1269
+ return { status: "timeout", progress: lastProgress };
1013
1270
  }
1014
1271
  function sleep(ms) {
1015
1272
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1197,7 +1454,7 @@ ${d.finalThoughts}
1197
1454
  }
1198
1455
  return output;
1199
1456
  }
1200
- function findFileInSnapshot2(files, path) {
1457
+ function findFileInSnapshot(files, path) {
1201
1458
  const normalizedPath = path.replace(/^\//, "").toLowerCase();
1202
1459
  const pathParts = normalizedPath.split("/");
1203
1460
  function search(items, remainingParts) {
@@ -1223,10 +1480,10 @@ function findFileInSnapshot2(files, path) {
1223
1480
  }
1224
1481
  let result = search(files, pathParts);
1225
1482
  if (result) return result;
1226
- result = searchByName2(files, pathParts[pathParts.length - 1]);
1483
+ result = searchByName(files, pathParts[pathParts.length - 1]);
1227
1484
  return result;
1228
1485
  }
1229
- function searchByName2(files, name) {
1486
+ function searchByName(files, name) {
1230
1487
  const nameLower = name.toLowerCase();
1231
1488
  for (const item of files) {
1232
1489
  const itemNameLower = item.name.toLowerCase();
@@ -1235,7 +1492,7 @@ function searchByName2(files, name) {
1235
1492
  return item;
1236
1493
  }
1237
1494
  if (item.kind === "folder" && item.children) {
1238
- const result = searchByName2(item.children, name);
1495
+ const result = searchByName(item.children, name);
1239
1496
  if (result) return result;
1240
1497
  }
1241
1498
  }
@@ -1244,6 +1501,36 @@ function searchByName2(files, name) {
1244
1501
 
1245
1502
  // src/core/tools/files.ts
1246
1503
  import { z as z4 } from "zod";
1504
+
1505
+ // src/core/ai-file-policy.ts
1506
+ var AI_WRITABLE_EXTENSIONS = [".md", ".markdown", ".txt"];
1507
+ var AI_STRUCTURED_TOOLS = {
1508
+ ".beats": "update_beat_board",
1509
+ ".timeline": "update_timeline",
1510
+ ".script": "update_script",
1511
+ ".map": "update_relationship_map",
1512
+ ".geomap": "update_world_map"
1513
+ };
1514
+ var AI_READONLY_EXTENSIONS = [
1515
+ ".character",
1516
+ ".location",
1517
+ ".json",
1518
+ ".yaml",
1519
+ ".yml"
1520
+ ];
1521
+ var AI_STRUCTURED_VIEWERS = {
1522
+ ".map": "view_relationship_map \u2014 renders the graph as an image plus a cast/relationship summary"
1523
+ };
1524
+ var AI_WRITE_POLICY_NOTE = `
1525
+ **Not every file type takes a generic write.**
1526
+ - Field-level tools only: ${Object.entries(AI_STRUCTURED_TOOLS).map(([ext, tool]) => `\`${ext}\` \u2192 \`${tool}\``).join(", ")}
1527
+ - Not writable at all: ${AI_READONLY_EXTENSIONS.map((e) => `\`${e}\``).join(", ")}
1528
+
1529
+ A refusal names the file type and what to do instead \u2014 it is information, not
1530
+ an obstacle to route around. Call \`get_capabilities\` for the authoritative
1531
+ table; this list ships with the client and can lag a deploy.`;
1532
+
1533
+ // src/core/tools/files.ts
1247
1534
  var SCRIPT_STUDIO_WRITE_WARNING = `
1248
1535
  **IMPORTANT \u2014 Script Studio books (\`book_type: "script"\`):**
1249
1536
  Writes are restricted to \`.script_studio/planning/**/*\` only.
@@ -1254,12 +1541,14 @@ For per-book details, read resource \`slima://books/{book_token}/schema\`.
1254
1541
 
1255
1542
  For Writing Studio books (\`book_type: "book"\`), all paths remain writable.
1256
1543
  Always check \`book_type\` via \`list_books\` or \`get_book\` before writing to an unfamiliar book.`;
1544
+ var AI_READONLY_WARNING = AI_WRITE_POLICY_NOTE;
1545
+ var CONTENT_TYPE_HINT = `How Slima classifies this file. "manuscript" = the book itself (prose/chapters) \u2014 this is what counts toward the author's word count, daily goal and writing streak. "reference" = notes, worldbuilding, research (never counted). "character" / "location" / "storyline" = planner cards, which are reference plus a label. Omit to inherit the containing folder (root-level files default to "reference"). If you were asked to WRITE a chapter, pass "manuscript" \u2014 otherwise those words will not appear in the author's word count.`;
1257
1546
  var PATH_PARAM_HINT = 'File path relative to book root. For Script Studio books: MUST be under ".script_studio/planning/" (e.g., ".script_studio/planning/references/outline.md"). For Writing Studio books: any path (e.g., "characters/protagonist.md").';
1258
1547
  var SCRIPT_STRUCTURED_EXTENSIONS = ["character", "scene", "storyline", "note", "location"];
1259
1548
  function registerFileTools(server, client) {
1260
1549
  server.tool(
1261
1550
  "read_file",
1262
- "Read the content of any file in a book by its path",
1551
+ "Read the content of any file in a book by its path. For `.map` relationship graphs, prefer `view_relationship_map` \u2014 this tool returns their raw JSON (node ids and pixel coordinates), which is hard to reason about.",
1263
1552
  {
1264
1553
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1265
1554
  path: z4.string().describe('File path (e.g., "characters/protagonist.md" or "chapter-01.md")')
@@ -1268,19 +1557,8 @@ function registerFileTools(server, client) {
1268
1557
  async ({ book_token, path }) => {
1269
1558
  try {
1270
1559
  const result = await client.readFile(book_token, path);
1271
- const fileInfo = [
1272
- `# ${result.file.name}`,
1273
- "",
1274
- `**Path:** ${result.file.path}`,
1275
- `**Words:** ${result.file.wordCount}`,
1276
- `**Type:** ${result.file.fileType || "unknown"}`,
1277
- "",
1278
- "---",
1279
- "",
1280
- result.content || "(empty file)"
1281
- ].join("\n");
1282
1560
  return {
1283
- content: [{ type: "text", text: fileInfo }]
1561
+ content: [{ type: "text", text: renderFileRead(result) }]
1284
1562
  };
1285
1563
  } catch (error) {
1286
1564
  return {
@@ -1293,7 +1571,8 @@ function registerFileTools(server, client) {
1293
1571
  server.tool(
1294
1572
  "write_file",
1295
1573
  `Replace the entire content of an existing file (creates a new commit).
1296
- ${SCRIPT_STUDIO_WRITE_WARNING}`,
1574
+ ${SCRIPT_STUDIO_WRITE_WARNING}
1575
+ ${AI_READONLY_WARNING}`,
1297
1576
  {
1298
1577
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1299
1578
  path: z4.string().describe(PATH_PARAM_HINT),
@@ -1329,67 +1608,36 @@ Message: ${result.commit.name || result.commit.message}`
1329
1608
  );
1330
1609
  server.tool(
1331
1610
  "edit_file",
1332
- `Edit a file by replacing a specific text segment (search and replace). Use this for precise edits instead of rewriting the entire file.
1333
- ${SCRIPT_STUDIO_WRITE_WARNING}`,
1611
+ `Replace a specific text segment in a file (search and replace). Use this for precise edits instead of rewriting the whole file.
1612
+
1613
+ The replacement is performed by the server inside a lock, so the reported count is what was actually written \u2014 not a local guess. If the text is missing or ambiguous the call fails with a message telling you what to do next; act on it rather than retrying the same arguments.
1614
+ ${SCRIPT_STUDIO_WRITE_WARNING}
1615
+ ${AI_READONLY_WARNING}`,
1334
1616
  {
1335
1617
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1336
1618
  path: z4.string().describe(PATH_PARAM_HINT),
1337
1619
  old_string: z4.string().describe("The exact text to find and replace (must match exactly, including whitespace)"),
1338
- new_string: z4.string().describe("The new text to replace it with"),
1339
- replace_all: z4.boolean().optional().describe("Replace all occurrences (default: false, replaces only first match)"),
1620
+ new_string: z4.string().describe("The new text to replace it with (pass an empty string to delete)"),
1621
+ replace_all: z4.boolean().optional().describe("Replace all occurrences (default: false, replaces only the first match)"),
1340
1622
  commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1341
1623
  },
1342
1624
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1343
1625
  async ({ book_token, path, old_string, new_string, replace_all, commit_message }) => {
1344
1626
  try {
1345
- const fileData = await client.readFile(book_token, path);
1346
- const currentContent = fileData.content || "";
1347
- if (!currentContent.includes(old_string)) {
1348
- return {
1349
- content: [
1350
- {
1351
- type: "text",
1352
- text: `\u274C Edit failed: Could not find the specified text in the file.
1353
-
1354
- **Looking for:**
1355
- \`\`\`
1356
- ${old_string.slice(0, 200)}${old_string.length > 200 ? "..." : ""}
1357
- \`\`\`
1358
-
1359
- **Tip:** Make sure the text matches exactly, including whitespace and line breaks.`
1360
- }
1361
- ],
1362
- isError: true
1363
- };
1364
- }
1365
- if (!replace_all) {
1366
- const occurrences = currentContent.split(old_string).length - 1;
1367
- if (occurrences > 1) {
1368
- return {
1369
- content: [
1370
- {
1371
- type: "text",
1372
- text: `\u274C Edit failed: Found ${occurrences} occurrences of the text. Please provide a more specific/longer text segment to ensure a unique match, or set replace_all: true to replace all occurrences.`
1373
- }
1374
- ],
1375
- isError: true
1376
- };
1377
- }
1378
- }
1379
- const newContent = replace_all ? currentContent.split(old_string).join(new_string) : currentContent.replace(old_string, new_string);
1380
- const result = await client.updateFile(book_token, {
1627
+ const result = await client.editFile(book_token, {
1381
1628
  path,
1382
- content: newContent,
1383
- commitMessage: commit_message || `Edit ${path}`
1629
+ oldString: old_string,
1630
+ newString: new_string,
1631
+ replaceAll: replace_all,
1632
+ commitMessage: commit_message
1384
1633
  });
1385
- const replacementCount = replace_all ? currentContent.split(old_string).length - 1 : 1;
1386
1634
  return {
1387
1635
  content: [
1388
1636
  {
1389
1637
  type: "text",
1390
- text: `\u2705 File edited successfully.
1638
+ text: `\u2705 File edited.
1391
1639
 
1392
- **Replacements:** ${replacementCount}
1640
+ **Replacements applied by the server:** ${result.replacements}
1393
1641
  **Commit:** ${result.commit.token}
1394
1642
  **Message:** ${result.commit.name || result.commit.message}`
1395
1643
  }
@@ -1406,23 +1654,34 @@ ${old_string.slice(0, 200)}${old_string.length > 200 ? "..." : ""}
1406
1654
  server.tool(
1407
1655
  "create_file",
1408
1656
  `Create a new file in a book (creates a new commit).
1409
- ${SCRIPT_STUDIO_WRITE_WARNING}`,
1657
+
1658
+ **Writing a chapter? Pass \`content_type: "manuscript"\`.** Files default to
1659
+ "reference" (not counted in the author's word count, streak, or manuscript export)
1660
+ unless they inherit a manuscript folder or you say otherwise.
1661
+ ${SCRIPT_STUDIO_WRITE_WARNING}
1662
+ ${AI_READONLY_WARNING}`,
1410
1663
  {
1411
1664
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1412
1665
  path: z4.string().describe(
1413
1666
  `${PATH_PARAM_HINT} Parent folders are auto-created (mkdir -p behavior).`
1414
1667
  ),
1415
1668
  content: z4.string().optional().describe("Initial content (optional, defaults to empty)"),
1669
+ content_type: z4.enum(["manuscript", "reference", "character", "location", "storyline"]).optional().describe(CONTENT_TYPE_HINT),
1416
1670
  commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1417
1671
  },
1418
1672
  { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1419
- async ({ book_token, path, content, commit_message }) => {
1673
+ async ({ book_token, path, content, content_type, commit_message }) => {
1420
1674
  try {
1421
1675
  const result = await client.createFile(book_token, {
1422
1676
  path,
1423
1677
  content: content || "",
1678
+ contentType: content_type,
1424
1679
  commitMessage: commit_message
1425
1680
  });
1681
+ const classification = content_type ? `Classified as: ${content_type}${content_type === "manuscript" ? " (counts toward the word count)" : " (not counted in the word count)"}` : "Classified by inheriting the parent folder (root-level defaults to reference, which is NOT counted in the word count).";
1682
+ const notes = (result.notes ?? []).map((n) => `
1683
+
1684
+ \u26A0\uFE0F ${n}`).join("");
1426
1685
  return {
1427
1686
  content: [
1428
1687
  {
@@ -1431,7 +1690,8 @@ ${SCRIPT_STUDIO_WRITE_WARNING}`,
1431
1690
 
1432
1691
  File Token: ${result.fileToken}
1433
1692
  Commit: ${result.commit.token}
1434
- Message: ${result.commit.name || result.commit.message}`
1693
+ Message: ${result.commit.name || result.commit.message}
1694
+ ${classification}${notes}`
1435
1695
  }
1436
1696
  ]
1437
1697
  };
@@ -1445,28 +1705,42 @@ Message: ${result.commit.name || result.commit.message}`
1445
1705
  );
1446
1706
  server.tool(
1447
1707
  "delete_file",
1448
- `Delete a file from a book (creates a new commit).
1449
- ${SCRIPT_STUDIO_WRITE_WARNING}`,
1708
+ `Delete a file or an empty folder from a book (creates a new commit).
1709
+
1710
+ A folder that still has anything inside it is refused unless you pass
1711
+ \`recursive: true\`. That is not bureaucracy: deleting only the folder would
1712
+ leave its contents with no parent, and the app then shows the whole subtree at
1713
+ the book's top level \u2014 the author sees their files scattered, with no error.
1714
+ Say the word and everything inside goes, and you are told how many.
1715
+ ${SCRIPT_STUDIO_WRITE_WARNING}
1716
+ ${AI_READONLY_WARNING}`,
1450
1717
  {
1451
1718
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1452
1719
  path: z4.string().describe(PATH_PARAM_HINT),
1453
- commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)")
1720
+ commit_message: z4.string().optional().describe("Commit message (optional, auto-generated if not provided)"),
1721
+ recursive: z4.boolean().optional().describe("Required to delete a folder that is not empty \u2014 everything inside goes with it")
1454
1722
  },
1455
1723
  { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
1456
- async ({ book_token, path, commit_message }) => {
1724
+ async ({ book_token, path, commit_message, recursive }) => {
1457
1725
  try {
1458
1726
  const result = await client.deleteFile(book_token, {
1459
1727
  path,
1460
- commitMessage: commit_message
1728
+ commitMessage: commit_message,
1729
+ recursive
1461
1730
  });
1462
1731
  return {
1463
1732
  content: [
1464
1733
  {
1465
1734
  type: "text",
1466
- text: `File deleted successfully.
1467
-
1468
- Commit: ${result.commit.token}
1469
- Message: ${result.commit.name || result.commit.message}`
1735
+ text: [
1736
+ "File deleted successfully.",
1737
+ // The model asked to delete one path; several may have gone. It has to
1738
+ // be able to tell the author that.
1739
+ result.deletedDescendants ? `${result.deletedDescendants} item(s) inside it were deleted too \u2014 tell the author.` : null,
1740
+ "",
1741
+ `Commit: ${result.commit.token}`,
1742
+ `Message: ${result.commit.name || result.commit.message}`
1743
+ ].filter((line) => line !== null).join("\n")
1470
1744
  }
1471
1745
  ]
1472
1746
  };
@@ -1481,7 +1755,8 @@ Message: ${result.commit.name || result.commit.message}`
1481
1755
  server.tool(
1482
1756
  "append_to_file",
1483
1757
  `Append content to the end of an existing file (creates a new commit).
1484
- ${SCRIPT_STUDIO_WRITE_WARNING}`,
1758
+ ${SCRIPT_STUDIO_WRITE_WARNING}
1759
+ ${AI_READONLY_WARNING}`,
1485
1760
  {
1486
1761
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1487
1762
  path: z4.string().describe(PATH_PARAM_HINT),
@@ -1515,6 +1790,61 @@ Message: ${result.commit.name || result.commit.message}`
1515
1790
  }
1516
1791
  }
1517
1792
  );
1793
+ server.tool(
1794
+ "view_relationship_map",
1795
+ `See a \`.map\` character-relationship graph as a picture plus a readable summary.
1796
+
1797
+ Use this instead of \`read_file\` for \`.map\` files: their raw JSON is node ids and pixel coordinates.
1798
+ The summary lists every character, every relationship ("A \u2014childhood friends\u2192 B") and any faction groups,
1799
+ so it is useful even in clients that do not display images.
1800
+
1801
+ \`.map\` files are read-only to AI \u2014 describe changes you recommend in words; the author edits them in the map editor.`,
1802
+ {
1803
+ book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1804
+ path: z4.string().describe('Path to a .map file (e.g., "world/relations.map")')
1805
+ },
1806
+ { readOnlyHint: true, openWorldHint: true },
1807
+ async ({ book_token, path }) => {
1808
+ try {
1809
+ const result = await client.visualizeFile(book_token, path);
1810
+ if (result.contentWithheld) {
1811
+ return {
1812
+ content: [
1813
+ {
1814
+ type: "text",
1815
+ text: `\u{1F512} "${path}" is a confidential team skill. Its contents are withheld from you by your team's settings (reason: ${result.withheldReason ?? "confidential"}). This is intentional, not an error \u2014 do not try to rewrite it.`
1816
+ }
1817
+ ]
1818
+ };
1819
+ }
1820
+ if (result.throttled) {
1821
+ return {
1822
+ content: [
1823
+ {
1824
+ type: "text",
1825
+ text: `\u23F3 Read budget for this team skill is exhausted for today (${result.throttledReason ?? "daily_read_limit"}). Try again later; the file is not empty.`
1826
+ }
1827
+ ]
1828
+ };
1829
+ }
1830
+ const content = [{ type: "text", text: result.summary ?? "(no summary available)" }];
1831
+ if (result.format === "png" && result.image) {
1832
+ content.push({ type: "image", data: result.image, mimeType: "image/png" });
1833
+ } else if (result.imageUnavailable) {
1834
+ content.push({
1835
+ type: "text",
1836
+ text: `_(The image could not be rendered on the server, so only the summary above is available.)_`
1837
+ });
1838
+ }
1839
+ return { content };
1840
+ } catch (error) {
1841
+ return {
1842
+ content: [{ type: "text", text: formatErrorForMcp(error) }],
1843
+ isError: true
1844
+ };
1845
+ }
1846
+ }
1847
+ );
1518
1848
  server.tool(
1519
1849
  "search_content",
1520
1850
  `Search for content across all files in a book.
@@ -1525,7 +1855,7 @@ Writing Studio books are unaffected by this filter.`,
1525
1855
  {
1526
1856
  book_token: z4.string().describe("Book token (e.g., bk_abc123)"),
1527
1857
  query: z4.string().describe('Search query (e.g., "blue eyes" or "protagonist name")'),
1528
- file_types: z4.array(z4.string()).optional().describe('Filter by file types (e.g., ["markdown", "txt"])'),
1858
+ file_types: z4.array(z4.string()).optional().describe('Filter by file extension, with or without the dot: ["md", ".txt", "beats"]'),
1529
1859
  limit: z4.number().optional().describe("Maximum number of matches to return (default: 20)"),
1530
1860
  include_structured: z4.boolean().optional().describe(
1531
1861
  "Script Studio only: set to true to include structured files (*.character / *.scene / etc.) in search results. Default: false (excluded)."
@@ -1603,6 +1933,485 @@ function isStructuredScriptFile(path) {
1603
1933
  return SCRIPT_STRUCTURED_EXTENSIONS.includes(ext);
1604
1934
  }
1605
1935
 
1936
+ // src/core/tools/structured.ts
1937
+ import { z as z5 } from "zod";
1938
+ var TYPES = [
1939
+ {
1940
+ tool: "update_beat_board",
1941
+ extension: ".beats",
1942
+ noun: "beat board",
1943
+ ops: "add_act, update_act, remove_act, add_beat, update_beat, remove_beat, move_beat",
1944
+ example: '{"op": "add_act", "name": "Act Two", "op_ref": "A2"}, {"op": "add_beat", "title": "The turn", "act_id": "@A2"}'
1945
+ },
1946
+ {
1947
+ tool: "update_timeline",
1948
+ extension: ".timeline",
1949
+ noun: "timeline",
1950
+ ops: "add_entry, update_entry, remove_entry, set_config",
1951
+ example: '{"op": "add_entry", "title": "The siege begins", "time_mode": "label", "time_label": "Winter"}'
1952
+ },
1953
+ {
1954
+ tool: "update_script",
1955
+ extension: ".script",
1956
+ noun: "screenplay",
1957
+ ops: "add_scene, update_scene_heading, remove_scene, add_element, update_element, remove_element",
1958
+ example: '{"op": "add_scene", "interior": true, "location": "HARBOUR", "op_ref": "S1"}, {"op": "add_element", "scene_id": "@S1", "type": "action", "text": "The fog lifts."}'
1959
+ },
1960
+ {
1961
+ tool: "update_relationship_map",
1962
+ extension: ".map",
1963
+ noun: "relationship map",
1964
+ ops: "add_node, update_node, move_node, remove_node, add_edge, update_edge, remove_edge, add_group, update_group, remove_group, set_meta",
1965
+ example: '{"op": "add_node", "label": "Lin Shuang", "op_ref": "N1", "position": {"near_id": "<id of Shen Yan>", "direction": "right"}}, {"op": "add_edge", "source_id": "<id of Shen Yan>", "target_id": "@N1", "label": "mentor"}',
1966
+ extra: '**Position is content here, not layout.** `update_node` will not move anything \u2014 that is\n`move_node`, so that "I moved her next to him" is its own reviewable change. Prefer\n`{near_id, direction}` over computing numbers. Existing nodes never move; the final\ncoordinates come back in the notes. Group boxes are computed from `member_ids` \u2014 never\nsend x/y/width/height. Removing a node removes its edges, and you are told so.'
1967
+ },
1968
+ {
1969
+ tool: "update_world_map",
1970
+ extension: ".geomap",
1971
+ noun: "world map",
1972
+ ops: "add_place, update_place, add_label, update_label, remove_element, add_river, add_border, paint_region, set_meta",
1973
+ example: '{"op": "add_place", "x": 210, "y": 96, "name": "Starhaven", "tier": "port"}, {"op": "add_label", "x": 240, "y": 60, "text": "The Cold Sea", "size": "large"}',
1974
+ extra: "**Coordinates are grid cells, not pixels**: x 0-519, y 0-363. `read_file` gives you the\nmap with its three terrain grids replaced by a note \u2014 they are machine-encoded and you\ndo not need them; `paint_region` works in cells. `remove_element` takes any element id\n(`pl_` place, `lb_` label, `rv_` river, `bd_` border). Terrain has no undo: paint over it\ninstead. Move a place with `update_place`, never by removing and re-adding it. An unknown\nplace `tier` is refused with the full list of real ones \u2014 read the error, do not guess."
1975
+ }
1976
+ ];
1977
+ var SHARED_RULES = `
1978
+ **Read the file first.** Every id you send must come from that read \u2014 never
1979
+ invent one, never retype one from memory. An unknown id is refused with the
1980
+ list of real ones, so a mistake costs a round trip, not the author's data.
1981
+
1982
+ **One operation per change.** Each operation takes only its own arguments; a
1983
+ field belonging to a different operation is refused rather than ignored.
1984
+
1985
+ **\`op_ref\` chains within one batch.** Label what an operation creates with
1986
+ \`op_ref: "A2"\`, then a LATER operation can point at it as \`"@A2"\`. Backward
1987
+ references only, and only inside this one batch \u2014 a later call must use the
1988
+ real id, which comes back in \`applied\`.
1989
+
1990
+ **The batch is all-or-nothing.** If one operation fails nothing is written, so
1991
+ you never have to work out how far it got.
1992
+
1993
+ **Unlike the in-app assistant, there is no review panel here.** What you send
1994
+ lands in the author's version history immediately. Say what you changed, not
1995
+ "please confirm it".`;
1996
+ function describe(type) {
1997
+ return `Change a Slima ${type.noun} (\`${type.extension}\`) one field at a time.
1998
+
1999
+ This is the ONLY way to write this file type \u2014 \`write_file\` and \`edit_file\`
2000
+ are refused for it, because a whole-file write would drop the parts of the
2001
+ document you were never shown.
2002
+
2003
+ Operations: ${type.ops}
2004
+ Example \`operations\`: [${type.example}]
2005
+ ${SHARED_RULES}${type.extra ? `
2006
+
2007
+ ${type.extra}` : ""}`;
2008
+ }
2009
+ function render(type, result) {
2010
+ const lines = [`Applied ${result.applied.length} change(s) to the ${type.noun}.`, ""];
2011
+ const created = result.applied.filter((a) => a.id);
2012
+ if (created.length > 0) {
2013
+ lines.push("**New ids** (use these in later calls \u2014 do not re-read just for them):");
2014
+ for (const a of created) lines.push(`- \`${a.op}\` \u2192 ${a.id}${a.op_ref ? ` (was @${a.op_ref})` : ""}`);
2015
+ lines.push("");
2016
+ }
2017
+ if (result.notes.length > 0) {
2018
+ lines.push("**Also happened, tell the author:**");
2019
+ for (const note of result.notes) lines.push(`- ${note}`);
2020
+ lines.push("");
2021
+ }
2022
+ lines.push(`**Commit:** ${result.commit.token}`);
2023
+ return lines.join("\n");
2024
+ }
2025
+ function registerStructuredTools(server, client) {
2026
+ for (const type of TYPES) {
2027
+ server.tool(
2028
+ type.tool,
2029
+ describe(type),
2030
+ {
2031
+ book_token: z5.string().describe("Book token (e.g., bk_abc123)"),
2032
+ path: z5.string().describe(`Path to the ${type.extension} file (e.g., "plan/board${type.extension}")`),
2033
+ intent: z5.string().describe(
2034
+ `One line in the author's own language saying what this batch does \u2014 it becomes the commit message they read later. Not "batch update".`
2035
+ ),
2036
+ operations: z5.array(z5.record(z5.unknown())).describe(
2037
+ "The changes, in order. Each is a flat object: `op` names the operation and the other keys are that operation's arguments. There is no nested `args`."
2038
+ )
2039
+ },
2040
+ { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2041
+ async ({ book_token, path, intent, operations }) => {
2042
+ try {
2043
+ const result = await client.applyStructuredOps(book_token, {
2044
+ path,
2045
+ intent,
2046
+ operations
2047
+ });
2048
+ return { content: [{ type: "text", text: render(type, result) }] };
2049
+ } catch (error) {
2050
+ return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
2051
+ }
2052
+ }
2053
+ );
2054
+ }
2055
+ }
2056
+
2057
+ // src/core/tools/teaching.ts
2058
+ import { z as z6 } from "zod";
2059
+ var READ_ONLY = {
2060
+ readOnlyHint: true,
2061
+ destructiveHint: false,
2062
+ idempotentHint: true,
2063
+ openWorldHint: true
2064
+ };
2065
+ function text(body) {
2066
+ return { content: [{ type: "text", text: body }] };
2067
+ }
2068
+ function failure(error) {
2069
+ return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
2070
+ }
2071
+ function registerTeachingTools(server, client) {
2072
+ server.tool(
2073
+ "get_capabilities",
2074
+ `What this Slima server supports: which file extensions an AI may write, the content types, the available tools, and the guide index.
2075
+
2076
+ Call this once at the start of a Slima session, before writing anything. Every field is derived from the contract the server actually enforces, so it cannot drift from real behaviour the way a written description can.`,
2077
+ {},
2078
+ READ_ONLY,
2079
+ async () => {
2080
+ try {
2081
+ return text(JSON.stringify(await client.getCapabilities(), null, 2));
2082
+ } catch (error) {
2083
+ return failure(error);
2084
+ }
2085
+ }
2086
+ );
2087
+ server.tool(
2088
+ "list_guides",
2089
+ `List the available Slima guides: titles and one-line summaries of when to load each.
2090
+
2091
+ Guides teach how to operate Slima \u2014 file structure, version control, team knowledge bases, the relationship-map format. Load one with \`load_guide\` when you are about to do the thing it covers.`,
2092
+ {},
2093
+ READ_ONLY,
2094
+ async () => {
2095
+ try {
2096
+ const result = await client.listGuides();
2097
+ const rows = result.guides.map((g) => `- **${g.slug}** \u2014 ${g.title}
2098
+ ${g.summary}`);
2099
+ return text(`## Slima guides
2100
+
2101
+ ${rows.join("\n")}`);
2102
+ } catch (error) {
2103
+ return failure(error);
2104
+ }
2105
+ }
2106
+ );
2107
+ server.tool(
2108
+ "load_guide",
2109
+ `Load one Slima guide in full.
2110
+
2111
+ Read \`book-structure\` before creating files, \`version-control-basics\` before a large rewrite, \`team-knowledge-base\` when working in a publisher's team, \`beta-reader\` before spending the author's credits on a report. \`list_guides\` has the current set.`,
2112
+ {
2113
+ slug: z6.string().describe('Guide slug, e.g. "book-structure" (from list_guides)')
2114
+ },
2115
+ READ_ONLY,
2116
+ async ({ slug }) => {
2117
+ try {
2118
+ const guide = await client.loadGuide(slug);
2119
+ return text(`# ${guide.title}
2120
+
2121
+ ${guide.body}`);
2122
+ } catch (error) {
2123
+ return failure(error);
2124
+ }
2125
+ }
2126
+ );
2127
+ server.tool(
2128
+ "list_writing_skills",
2129
+ `List Slima's writing skills \u2014 the craft procedures the in-app coach follows for tasks like developing a character, revising a scene, or diagnosing pacing.
2130
+
2131
+ Use these when the task is about the WRITING. Use \`list_guides\` when the task is about operating Slima.`,
2132
+ {},
2133
+ READ_ONLY,
2134
+ async () => {
2135
+ try {
2136
+ const result = await client.listWritingSkills();
2137
+ const rows = result.skills.map((s) => `- **${s.key}** \u2014 ${s.name}: ${s.description}`);
2138
+ return text(`## Writing skills
2139
+
2140
+ ${rows.join("\n")}`);
2141
+ } catch (error) {
2142
+ return failure(error);
2143
+ }
2144
+ }
2145
+ );
2146
+ server.tool(
2147
+ "load_writing_skill",
2148
+ `Load one writing skill in full \u2014 a step-by-step procedure plus the questions a coach would ask.
2149
+
2150
+ Follow it rather than summarising it at the author.`,
2151
+ {
2152
+ key: z6.string().describe('Skill key, e.g. "character_develop" (from list_writing_skills)')
2153
+ },
2154
+ READ_ONLY,
2155
+ async ({ key }) => {
2156
+ try {
2157
+ const skill = await client.loadWritingSkill(key);
2158
+ return text(`# ${skill.name}
2159
+
2160
+ ${skill.body}`);
2161
+ } catch (error) {
2162
+ return failure(error);
2163
+ }
2164
+ }
2165
+ );
2166
+ }
2167
+
2168
+ // src/core/tools/teams.ts
2169
+ import { z as z7 } from "zod";
2170
+ var READ_ONLY2 = {
2171
+ readOnlyHint: true,
2172
+ destructiveHint: false,
2173
+ idempotentHint: true,
2174
+ openWorldHint: true
2175
+ };
2176
+ function json(body) {
2177
+ return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
2178
+ }
2179
+ function failure2(error) {
2180
+ return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
2181
+ }
2182
+ var TEAM_TOKEN = z7.string().describe("Team token (from list_teams)");
2183
+ function registerTeamTools(server, client) {
2184
+ server.tool(
2185
+ "list_teams",
2186
+ `List the teams this account belongs to \u2014 publishers, studios, writing groups.
2187
+
2188
+ A team book carries house rules and shared knowledge that change how you should write for it. If a book belongs to a team, read \`list_team_rules\` before writing.`,
2189
+ {},
2190
+ READ_ONLY2,
2191
+ async () => {
2192
+ try {
2193
+ return json(await client.listTeams());
2194
+ } catch (error) {
2195
+ return failure2(error);
2196
+ }
2197
+ }
2198
+ );
2199
+ server.tool(
2200
+ "get_team",
2201
+ "Get one team: its members and their roles.",
2202
+ { team_token: TEAM_TOKEN },
2203
+ READ_ONLY2,
2204
+ async ({ team_token }) => {
2205
+ try {
2206
+ return json(await client.getTeam(team_token));
2207
+ } catch (error) {
2208
+ return failure2(error);
2209
+ }
2210
+ }
2211
+ );
2212
+ server.tool(
2213
+ "list_team_rules",
2214
+ `The team's coaching rules \u2014 its house style \u2014 each marked mandatory or advisory.
2215
+
2216
+ Read these BEFORE writing for a team book, not after a draft is rejected. Mandatory rules are not suggestions: prose that ignores one gets re-edited by a person, which is worse than not writing it.`,
2217
+ { team_token: TEAM_TOKEN },
2218
+ READ_ONLY2,
2219
+ async ({ team_token }) => {
2220
+ try {
2221
+ return json(await client.listTeamRules(team_token));
2222
+ } catch (error) {
2223
+ return failure2(error);
2224
+ }
2225
+ }
2226
+ );
2227
+ server.tool(
2228
+ "list_team_memories",
2229
+ `What the team has established over time: recurring characters, naming conventions, continuity spanning books, decisions already argued through.
2230
+
2231
+ Treat these as settled. If one contradicts a book's text, say so rather than picking a side.`,
2232
+ { team_token: TEAM_TOKEN },
2233
+ READ_ONLY2,
2234
+ async ({ team_token }) => {
2235
+ try {
2236
+ return json(await client.listTeamMemories(team_token));
2237
+ } catch (error) {
2238
+ return failure2(error);
2239
+ }
2240
+ }
2241
+ );
2242
+ server.tool(
2243
+ "list_team_skills",
2244
+ `The team's own writing procedures.
2245
+
2246
+ Some are marked \`confidential: true\` and come back WITHOUT a source path; their file contents are withheld from this connection. That is deliberate, and it does not stop the skill working \u2014 the server applies it on the team's behalf. Do not try to reconstruct one, reproduce it verbatim, or reach its contents by another route: those routes are closed and audited. Read the \`team-knowledge-base\` guide before working in a team knowledge base.`,
2247
+ { team_token: TEAM_TOKEN },
2248
+ READ_ONLY2,
2249
+ async ({ team_token }) => {
2250
+ try {
2251
+ return json(await client.listTeamSkills(team_token));
2252
+ } catch (error) {
2253
+ return failure2(error);
2254
+ }
2255
+ }
2256
+ );
2257
+ }
2258
+
2259
+ // src/core/tools/workspace.ts
2260
+ import { z as z8 } from "zod";
2261
+ var READ_ONLY3 = {
2262
+ readOnlyHint: true,
2263
+ destructiveHint: false,
2264
+ idempotentHint: true,
2265
+ openWorldHint: true
2266
+ };
2267
+ var WRITES = {
2268
+ readOnlyHint: false,
2269
+ destructiveHint: false,
2270
+ idempotentHint: false,
2271
+ openWorldHint: true
2272
+ };
2273
+ var BOOK_TOKEN = z8.string().describe("Book token (bk_...)");
2274
+ function json2(body) {
2275
+ return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
2276
+ }
2277
+ function failure3(error) {
2278
+ return { content: [{ type: "text", text: formatErrorForMcp(error) }], isError: true };
2279
+ }
2280
+ function registerWorkspaceTools(server, client) {
2281
+ server.tool(
2282
+ "list_notes",
2283
+ "The author's margin notes on this book \u2014 book-level and chapter-level, including foreshadowing pairs.",
2284
+ { book_token: BOOK_TOKEN },
2285
+ READ_ONLY3,
2286
+ async ({ book_token }) => {
2287
+ try {
2288
+ return json2(await client.listNotes(book_token));
2289
+ } catch (error) {
2290
+ return failure3(error);
2291
+ }
2292
+ }
2293
+ );
2294
+ server.tool(
2295
+ "create_note",
2296
+ `Leave a note beside the manuscript. Notes do not touch the prose or the word count.
2297
+
2298
+ Use \`note_type: "chapter"\` with the file's token in \`file_id\` to anchor it to one chapter; \`"book"\` for the work as a whole. Do not use notes as your own scratchpad \u2014 they appear in the author's interface as their own thinking. See the \`notes-and-foreshadowing\` guide.`,
2299
+ {
2300
+ book_token: BOOK_TOKEN,
2301
+ title: z8.string().describe("Short title (required)"),
2302
+ content: z8.string().optional().describe("Body, up to 50,000 characters"),
2303
+ note_type: z8.enum(["book", "chapter"]).optional().describe('Defaults to "book"'),
2304
+ file_id: z8.string().optional().describe('File token \u2014 required when note_type is "chapter"'),
2305
+ color: z8.enum(["gray", "red", "blue", "green", "yellow", "purple"]).optional()
2306
+ },
2307
+ WRITES,
2308
+ async ({ book_token, title, content, note_type, file_id, color }) => {
2309
+ try {
2310
+ return json2(
2311
+ await client.createNote(book_token, {
2312
+ title,
2313
+ content,
2314
+ note_type: note_type ?? "book",
2315
+ file_id,
2316
+ color
2317
+ })
2318
+ );
2319
+ } catch (error) {
2320
+ return failure3(error);
2321
+ }
2322
+ }
2323
+ );
2324
+ server.tool(
2325
+ "semantic_search",
2326
+ `Find passages by MEANING rather than by the words used.
2327
+
2328
+ \`search_content\` finds the word an author typed; this finds the passage they meant \u2014 "where does the mother's resentment surface?" returns the scene even when neither word appears in it. On a team knowledge base it is the difference between browsing and asking.
2329
+
2330
+ Costs a small embedding charge, unlike literal search. Returns \xA7N paragraph anchors so you can quote them back precisely.`,
2331
+ {
2332
+ book_token: BOOK_TOKEN,
2333
+ query: z8.string().describe("What you are looking for, in natural language"),
2334
+ limit: z8.number().optional().describe("Max results (default 10, cap 50)")
2335
+ },
2336
+ { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
2337
+ async ({ book_token, query, limit }) => {
2338
+ try {
2339
+ return json2(await client.semanticSearch(book_token, query, limit));
2340
+ } catch (error) {
2341
+ return failure3(error);
2342
+ }
2343
+ }
2344
+ );
2345
+ server.tool(
2346
+ "list_comments",
2347
+ `Editorial comment threads on this book, open and resolved.
2348
+
2349
+ Read these before starting work on a book you have been handed \u2014 they are where an editor has already said what is wrong, and producing an independent list without reading them usually contradicts a decision someone already made.`,
2350
+ { book_token: BOOK_TOKEN },
2351
+ READ_ONLY3,
2352
+ async ({ book_token }) => {
2353
+ try {
2354
+ return json2(await client.listComments(book_token));
2355
+ } catch (error) {
2356
+ return failure3(error);
2357
+ }
2358
+ }
2359
+ );
2360
+ server.tool(
2361
+ "create_comment",
2362
+ `Leave an editorial comment.
2363
+
2364
+ Comment when the change is a judgement call \u2014 pacing, whether a motivation lands, whether a scene earns its length. Edit the file instead when the author asked for the change. When unsure, comment: a comment costs ten seconds to dismiss, an unwanted rewrite costs the author a diff of their own prose.`,
2365
+ {
2366
+ book_token: BOOK_TOKEN,
2367
+ content: z8.string().describe("The comment"),
2368
+ file_token: z8.string().optional().describe("File this comment is about, if any")
2369
+ },
2370
+ WRITES,
2371
+ async ({ book_token, content, file_token }) => {
2372
+ try {
2373
+ return json2(await client.createComment(book_token, { content, file_token }));
2374
+ } catch (error) {
2375
+ return failure3(error);
2376
+ }
2377
+ }
2378
+ );
2379
+ server.tool(
2380
+ "resolve_comment",
2381
+ `Mark a comment thread addressed.
2382
+
2383
+ Say what you did when you resolve one. A silent resolve tells the editor nothing about whether you understood the note.`,
2384
+ {
2385
+ book_token: BOOK_TOKEN,
2386
+ comment_token: z8.string().describe("Comment token (from list_comments)")
2387
+ },
2388
+ WRITES,
2389
+ async ({ book_token, comment_token }) => {
2390
+ try {
2391
+ return json2(await client.resolveComment(book_token, comment_token));
2392
+ } catch (error) {
2393
+ return failure3(error);
2394
+ }
2395
+ }
2396
+ );
2397
+ server.tool(
2398
+ "reopen_comment",
2399
+ "Reopen a comment thread that was resolved but should not have been.",
2400
+ {
2401
+ book_token: BOOK_TOKEN,
2402
+ comment_token: z8.string().describe("Comment token (from list_comments)")
2403
+ },
2404
+ WRITES,
2405
+ async ({ book_token, comment_token }) => {
2406
+ try {
2407
+ return json2(await client.reopenComment(book_token, comment_token));
2408
+ } catch (error) {
2409
+ return failure3(error);
2410
+ }
2411
+ }
2412
+ );
2413
+ }
2414
+
1606
2415
  // src/core/resources/book_schema.ts
1607
2416
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1608
2417
  var BOOK_SCHEMA_URI_TEMPLATE = "slima://books/{book_token}/schema";
@@ -1643,11 +2452,12 @@ function registerBookSchemaResource(server, client) {
1643
2452
  }
1644
2453
  function buildBookSchema(book) {
1645
2454
  const isScript = book.bookType === "script";
2455
+ const isKnowledge = book.bookType === "knowledge";
1646
2456
  return {
1647
2457
  book_token: book.token,
1648
2458
  book_title: book.title,
1649
2459
  book_type: book.bookType ?? "book",
1650
- studio_label: isScript ? "Script Studio" : "Writing Studio",
2460
+ studio_label: isScript ? "Script Studio" : isKnowledge ? "Team Knowledge Base" : "Writing Studio",
1651
2461
  mcp_capabilities: {
1652
2462
  // Note: actual reads still go through validate_safe_path!
1653
2463
  // (blocks absolute paths, traversal, control chars) even though it's
@@ -1666,7 +2476,29 @@ function buildBookSchema(book) {
1666
2476
  ".script_studio/notes/**/*.note",
1667
2477
  ".script_studio/planning/.initialized"
1668
2478
  ] : [],
1669
- reserved_sentinels: isScript ? [".script_studio/planning/.initialized"] : []
2479
+ reserved_sentinels: isScript ? [".script_studio/planning/.initialized"] : [],
2480
+ // Path allowlists are not the whole story on a team book: the server also
2481
+ // checks the caller's ROLE. An editor gets 403 on every write no matter
2482
+ // which path, so "writable_paths" above is an upper bound, not a promise.
2483
+ write_access_note: isKnowledge ? "Team knowledge base: owner / admin / seated writer may write; an editor is read-only (every write returns 403). Files under .slima/skills/ may be enabled as team skills by a manager." : "Personal books: the owner and writer collaborators may write; editor collaborators are read-only."
2484
+ },
2485
+ // AI file-type policy — mirrors slima_vue `fileTypeRegistry.ts` (`aiPolicy`).
2486
+ // Applies to BOTH studio types; the Script Studio path allowlist above is a
2487
+ // separate, additional restriction.
2488
+ ai_file_policy: {
2489
+ writable: [...AI_WRITABLE_EXTENSIONS],
2490
+ // Structured types are not one bucket. Collapsing them into `readonly`
2491
+ // is what made this resource tell the model `.map` could not be written
2492
+ // for weeks after it could, and never mention `.beats` at all.
2493
+ field_level_tools: AI_STRUCTURED_TOOLS,
2494
+ readonly: [...AI_READONLY_EXTENSIONS],
2495
+ readonly_note: "Read-only means the AI must not write these via write_file / edit_file / create_file / append_to_file / delete_file. Slima owns their structure; overwriting one corrupts it for the author. Recommend changes in words and point the author at that file type's editor.",
2496
+ field_level_note: "These take one change at a time through the tool named above. Every generic write is refused (write_file / edit_file / append_to_file), because a whole-document write drops the parts of the file you were never shown \u2014 a beat board loses its chapter cards, a relationship map loses a character, and nothing reports it. create_file works: it writes an empty document of the right shape and ignores the content you send, so you then fill it in with the tool.",
2497
+ // ⚠️ This block ships with the client and can lag a Rails deploy.
2498
+ // `get_capabilities` is derived from the contract Rails enforces; when the
2499
+ // two disagree, that one is right.
2500
+ authority: "get_capabilities",
2501
+ viewers: AI_STRUCTURED_VIEWERS
1670
2502
  },
1671
2503
  structured_file_types: isScript ? {
1672
2504
  "series.json": "Series metadata (title, genre, logline, theme, synopsis, acts)",
@@ -1678,21 +2510,25 @@ function buildBookSchema(book) {
1678
2510
  "*.storyline": "Storyline with tier and ID linkage",
1679
2511
  "*.note": "Note with scope"
1680
2512
  } : {},
1681
- notes: isScript ? "Structured files require schema-aware edits \u2014 use the Script Studio UI. MCP write access is restricted to planning/ (free-form reference tree) to prevent schema corruption." : "Writing Studio books are free-form. No schema restrictions; any path is writable.",
2513
+ notes: isScript ? "Structured files require schema-aware edits \u2014 use the Script Studio UI. MCP write access is restricted to planning/ (free-form reference tree) to prevent schema corruption." : isKnowledge ? "Team knowledge base. Free-form paths, but two team rules apply: writes follow team role (see write_access_note), and a file marked confidential is returned WITHOUT its content \u2014 read_file reports it as withheld rather than empty. That is intentional: the AI may use such a skill without reading it. Never try to rewrite a withheld file." : "Writing Studio books are free-form. No schema restrictions; any path is writable.",
1682
2514
  policy_source: "Rails: Mcp::FileOperationService#validate_studio_write_path!"
1683
2515
  };
1684
2516
  }
1685
2517
 
1686
2518
  // src/core/server-instructions.ts
1687
- var SERVER_INSTRUCTIONS = `Slima is an AI writing IDE with two product lines, distinguished by the \`book_type\` field on every book:
2519
+ var SERVER_INSTRUCTIONS = `Slima is an AI writing IDE for long-form fiction. You are connected to an author's account.
2520
+
2521
+ **Start by calling \`get_capabilities\`.** It returns what this server supports \u2014 which file types you may write, how content types work, the tools available, and the guide index \u2014 derived from the contract the server enforces, so it is never out of date.
1688
2522
 
1689
- 1. **Writing Studio** (\`book_type: "book"\`) \u2014 free-form markdown file tree for novels and long-form prose. All paths are writable via MCP.
2523
+ Then \`load_guide\` what the task needs: \`getting-started\` for the shape of a book, \`book-structure\` before creating files, \`version-control-basics\` before a large rewrite, \`team-knowledge-base\` inside a publisher's team, \`beta-reader\` before spending the author's credits.
1690
2524
 
1691
- 2. **Script Studio** (\`book_type: "script"\`) \u2014 structured schema for screenwriting with series / seasons / episodes / scenes / characters / locations / storylines / notes. Via MCP, only \`.script_studio/planning/**/*\` is writable; all structured files (series.json, *.character, *.scene, *.storyline, *.note, *.location, season.json, episode.json) and the \`.script_studio/planning/.initialized\` sentinel are READ-ONLY. Structural edits must happen through the Slima app UI.
2525
+ Three things worth knowing before your first call:
1692
2526
 
1693
- **Before writing to a book you haven't inspected this session:** call \`list_books\` or \`get_book\` to see \`book_type\`. For per-book write rules, read the resource \`slima://books/{book_token}/schema\`.
2527
+ - **Every write is a commit.** There is no save step, and the author sees your commits beside their own. Write real commit messages.
2528
+ - **The author may be editing the same file.** Prefer \`edit_file\` over \`update_file\`: an edit that cannot find its anchor fails loudly, where a whole-file write silently replaces whatever they just typed.
2529
+ - **Not every file type is yours to write.** A refusal naming the file type is information, not an obstacle to route around.
1694
2530
 
1695
- Reads (\`read_file\`, \`search_content\`, \`get_book_structure\`) work the same way for both studio types \u2014 AI coaching, critique, and analysis are fully supported even on Script Studio books.`;
2531
+ Slima's AI is a writing coach by default. When a request is vague or the author is thinking out loud, ask the question that unblocks them. When they ask plainly \u2014 "write this scene", "rewrite this paragraph" \u2014 do it, without lecturing.`;
1696
2532
 
1697
2533
  // src/core/utils/logger.ts
1698
2534
  var isDebug = process.env.DEBUG === "true" || process.env.DEBUG === "1";
@@ -1732,16 +2568,20 @@ function createSlimaServer(config) {
1732
2568
  const server = new McpServer(
1733
2569
  {
1734
2570
  name: "slima",
1735
- version: "0.2.0"
2571
+ version: "0.3.0"
1736
2572
  },
1737
2573
  {
1738
2574
  instructions: SERVER_INSTRUCTIONS
1739
2575
  }
1740
2576
  );
2577
+ registerTeachingTools(server, client);
1741
2578
  registerBookTools(server, client);
2579
+ registerTeamTools(server, client);
2580
+ registerWorkspaceTools(server, client);
1742
2581
  registerContentTools(server, client);
1743
2582
  registerBetaReaderTools(server, client, logger);
1744
2583
  registerFileTools(server, client);
2584
+ registerStructuredTools(server, client);
1745
2585
  registerBookSchemaResource(server, client);
1746
2586
  logger.info("Slima MCP Server initialized");
1747
2587
  logger.info(`API endpoint: ${config.baseUrl || "https://api.slima.ai"}`);
@@ -1954,7 +2794,7 @@ function getSuccessHtml() {
1954
2794
  </body>
1955
2795
  </html>`;
1956
2796
  }
1957
- function escapeHtml(text) {
2797
+ function escapeHtml(text2) {
1958
2798
  const map = {
1959
2799
  "&": "&amp;",
1960
2800
  "<": "&lt;",
@@ -1962,7 +2802,7 @@ function escapeHtml(text) {
1962
2802
  '"': "&quot;",
1963
2803
  "'": "&#039;"
1964
2804
  };
1965
- return text.replace(/[&<>"']/g, (char) => map[char]);
2805
+ return text2.replace(/[&<>"']/g, (char) => map[char]);
1966
2806
  }
1967
2807
  function getErrorHtml(message) {
1968
2808
  const safeMessage = escapeHtml(message);
@@ -2189,7 +3029,7 @@ async function runStatus() {
2189
3029
  }
2190
3030
 
2191
3031
  // src/cli/index.ts
2192
- var VERSION = "0.2.0";
3032
+ var VERSION = "0.3.0";
2193
3033
  function showHelp() {
2194
3034
  console.log(`
2195
3035
  Slima MCP v${VERSION}
@@ -2266,7 +3106,7 @@ async function startServer() {
2266
3106
  baseUrl
2267
3107
  });
2268
3108
  const transport = new StdioServerTransport();
2269
- await server.connect(transport);
3109
+ await server.connect(withGeminiSchemaCompat(transport));
2270
3110
  logger.info("Slima MCP Server running on stdio");
2271
3111
  process.on("SIGINT", async () => {
2272
3112
  logger.info("Shutting down...");