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 +1097 -257
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
107
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
624
|
-
|
|
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
|
-
- **
|
|
641
|
-
- **
|
|
642
|
-
|
|
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
|
|
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
|
|
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
|
|
670
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
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
|
-
- ${
|
|
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: `
|
|
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
|
|
735
|
-
|
|
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
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
785
|
-
|
|
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
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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 =
|
|
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: `
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1483
|
+
result = searchByName(files, pathParts[pathParts.length - 1]);
|
|
1227
1484
|
return result;
|
|
1228
1485
|
}
|
|
1229
|
-
function
|
|
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 =
|
|
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:
|
|
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
|
-
`
|
|
1333
|
-
|
|
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
|
|
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
|
-
|
|
1383
|
-
|
|
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
|
|
1638
|
+
text: `\u2705 File edited.
|
|
1391
1639
|
|
|
1392
|
-
**Replacements:** ${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2525
|
+
Three things worth knowing before your first call:
|
|
1692
2526
|
|
|
1693
|
-
**
|
|
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
|
-
|
|
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.
|
|
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(
|
|
2797
|
+
function escapeHtml(text2) {
|
|
1958
2798
|
const map = {
|
|
1959
2799
|
"&": "&",
|
|
1960
2800
|
"<": "<",
|
|
@@ -1962,7 +2802,7 @@ function escapeHtml(text) {
|
|
|
1962
2802
|
'"': """,
|
|
1963
2803
|
"'": "'"
|
|
1964
2804
|
};
|
|
1965
|
-
return
|
|
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.
|
|
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...");
|