@hanna84/mcp-writing 2.12.10 → 2.12.11

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/CHANGELOG.md CHANGED
@@ -4,11 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v2.12.11](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.12.10...v2.12.11)
9
+
10
+ - refactor(core): move helpers under src/core [`#126`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/126)
12
+
7
13
  #### [v2.12.10](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.12.9...v2.12.10)
9
15
 
16
+ > 29 April 2026
17
+
10
18
  - refactor(scripts): move async job runner under src/scripts [`#125`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/125)
20
+ - Release 2.12.10 [`fa20160`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/fa20160423211dd6ab7416a82fdf3365b69ae6d1)
12
22
 
13
23
  #### [v2.12.9](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.12.8...v2.12.9)
package/helpers.js CHANGED
@@ -1,345 +1 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import matter from "gray-matter";
4
- import yaml from "js-yaml";
5
- import { sidecarPath, syncAll } from "./src/sync/sync.js";
6
- import {
7
- slugifyEntityName,
8
- renderCharacterSheetTemplate,
9
- renderPlaceSheetTemplate,
10
- renderCharacterArcTemplate,
11
- } from "./src/world/world-entity-templates.js";
12
- import { ReviewBundlePlanError } from "./src/review-bundles/review-bundles.js";
13
-
14
- export function deriveLoglineFromProse(prose) {
15
- const compact = prose.replace(/\s+/g, " ").trim();
16
- if (!compact) return null;
17
- const sentence = compact.match(/^(.+?[.!?])(?:\s|$)/);
18
- const candidate = (sentence?.[1] ?? compact).trim();
19
- if (candidate.length <= 220) return candidate;
20
- return `${candidate.slice(0, 217).trimEnd()}...`;
21
- }
22
-
23
- export function inferCharacterIdsFromProse(dbHandle, prose, projectId) {
24
- const lower = prose.toLowerCase();
25
- const rows = dbHandle.prepare(`
26
- SELECT character_id, name
27
- FROM characters
28
- WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
29
- ORDER BY length(name) DESC
30
- `).all(projectId, projectId);
31
-
32
- const found = [];
33
- for (const row of rows) {
34
- if (!row.name) continue;
35
- const words = row.name.toLowerCase().split(/\s+/).filter(Boolean);
36
- if (words.length && words.every(w => lower.includes(w))) {
37
- found.push(row.character_id);
38
- }
39
- }
40
- return [...new Set(found)].slice(0, 12);
41
- }
42
-
43
- export function readSupportingNotesForEntity(filePath) {
44
- const ext = path.extname(filePath).toLowerCase();
45
- const base = path.basename(filePath, ext).toLowerCase();
46
- if (base !== "sheet") return [];
47
-
48
- const dir = path.dirname(filePath);
49
- let entries;
50
- try {
51
- entries = fs.readdirSync(dir, { withFileTypes: true });
52
- } catch {
53
- return [];
54
- }
55
-
56
- return entries
57
- .filter(entry => entry.isFile())
58
- .map(entry => entry.name)
59
- .filter(name => /\.(md|txt)$/i.test(name))
60
- .filter(name => !/^sheet\.(md|txt)$/i.test(name))
61
- .sort((a, b) => a.localeCompare(b))
62
- .map(name => {
63
- const notePath = path.join(dir, name);
64
- try {
65
- const raw = fs.readFileSync(notePath, "utf8");
66
- const { content } = matter(raw);
67
- return {
68
- file_name: name,
69
- content: content.trim(),
70
- };
71
- } catch {
72
- return null;
73
- }
74
- })
75
- .filter(Boolean)
76
- .filter(note => note.content);
77
- }
78
-
79
- export function readEntityMetadata(filePath) {
80
- const metaPath = sidecarPath(filePath);
81
- if (fs.existsSync(metaPath)) {
82
- try {
83
- return yaml.load(fs.readFileSync(metaPath, "utf8")) ?? {};
84
- } catch {
85
- return {};
86
- }
87
- }
88
-
89
- try {
90
- return matter(fs.readFileSync(filePath, "utf8")).data ?? {};
91
- } catch {
92
- return {};
93
- }
94
- }
95
-
96
- export function resolveBatchTargetScenes(dbHandle, {
97
- projectId,
98
- sceneIds,
99
- part,
100
- chapter,
101
- onlyStale,
102
- }) {
103
- const projectExists = Boolean(
104
- dbHandle.prepare(`SELECT 1 FROM projects WHERE project_id = ? LIMIT 1`).get(projectId)
105
- );
106
-
107
- if (sceneIds?.length) {
108
- const placeholders = sceneIds.map(() => "?").join(",");
109
- const existingRows = dbHandle.prepare(
110
- `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
111
- ).all(projectId, ...sceneIds);
112
- const existing = new Set(existingRows.map(row => row.scene_id));
113
- const missing = sceneIds.filter(sceneId => !existing.has(sceneId));
114
- if (missing.length > 0) {
115
- return { ok: false, code: "NOT_FOUND", message: `Requested scene IDs were not found in project '${projectId}'.`, details: { missing_scene_ids: missing, project_id: projectId } };
116
- }
117
- }
118
-
119
- const conditions = ["project_id = ?"];
120
- const params = [projectId];
121
-
122
- if (sceneIds?.length) {
123
- const placeholders = sceneIds.map(() => "?").join(",");
124
- conditions.push(`scene_id IN (${placeholders})`);
125
- params.push(...sceneIds);
126
- }
127
- if (part !== undefined) {
128
- conditions.push("part = ?");
129
- params.push(part);
130
- }
131
- if (chapter !== undefined) {
132
- conditions.push("chapter = ?");
133
- params.push(chapter);
134
- }
135
- if (onlyStale) {
136
- conditions.push("metadata_stale = 1");
137
- }
138
-
139
- const query = `
140
- SELECT scene_id, project_id, file_path
141
- FROM scenes
142
- WHERE ${conditions.join(" AND ")}
143
- ORDER BY part, chapter, timeline_position
144
- `;
145
-
146
- return {
147
- ok: true,
148
- rows: dbHandle.prepare(query).all(...params),
149
- project_exists: projectExists,
150
- };
151
- }
152
-
153
- export function createHelpers({ syncDir, syncDirReal, syncDirAbs, db, syncDirWritable }) {
154
- function isPathInsideSyncDir(candidatePath) {
155
- const resolvedCandidate = path.resolve(candidatePath);
156
- const canonicalCandidate = (() => {
157
- try {
158
- return fs.realpathSync(resolvedCandidate);
159
- } catch {
160
- return resolvedCandidate;
161
- }
162
- })();
163
-
164
- const rel = path.relative(syncDirReal, canonicalCandidate);
165
- return !(rel.startsWith("..") || path.isAbsolute(rel));
166
- }
167
-
168
- // Like isPathInsideSyncDir, but works for paths that do not yet exist by
169
- // walking up to the nearest existing ancestor before canonicalising.
170
- function isPathCandidateInsideSyncDir(candidatePath) {
171
- const resolvedCandidate = path.resolve(candidatePath);
172
-
173
- let existingAncestor = resolvedCandidate;
174
- while (!fs.existsSync(existingAncestor)) {
175
- const parent = path.dirname(existingAncestor);
176
- if (parent === existingAncestor) break;
177
- existingAncestor = parent;
178
- }
179
-
180
- const canonicalBase = (() => {
181
- try {
182
- return fs.realpathSync(existingAncestor);
183
- } catch {
184
- return existingAncestor;
185
- }
186
- })();
187
-
188
- const canonical = path.resolve(canonicalBase, path.relative(existingAncestor, resolvedCandidate));
189
- const rel = path.relative(syncDirReal, canonical);
190
- return !(rel.startsWith("..") || path.isAbsolute(rel));
191
- }
192
-
193
- function resolveOutputDirWithinSync(outputDir) {
194
- let resolvedOutputDir = path.resolve(outputDir);
195
- let existingAncestor = resolvedOutputDir;
196
-
197
- while (!fs.existsSync(existingAncestor)) {
198
- const parentDir = path.dirname(existingAncestor);
199
- if (parentDir === existingAncestor) {
200
- throw new ReviewBundlePlanError(
201
- "INVALID_OUTPUT_DIR",
202
- "output_dir must be inside WRITING_SYNC_DIR.",
203
- { output_dir: resolvedOutputDir, sync_dir: syncDirAbs }
204
- );
205
- }
206
- existingAncestor = parentDir;
207
- }
208
-
209
- let realExistingAncestor;
210
- try {
211
- realExistingAncestor = fs.realpathSync.native(existingAncestor);
212
- } catch (err) {
213
- throw new ReviewBundlePlanError(
214
- "INVALID_OUTPUT_DIR",
215
- "output_dir ancestor could not be resolved: path may be inaccessible.",
216
- { output_dir: outputDir, existing_ancestor: existingAncestor, cause: err.message }
217
- );
218
- }
219
- const relativeFromAncestor = path.relative(existingAncestor, resolvedOutputDir);
220
- resolvedOutputDir = path.resolve(realExistingAncestor, relativeFromAncestor);
221
-
222
- const relativeToSyncDir = path.relative(syncDirReal, resolvedOutputDir);
223
- if (relativeToSyncDir.startsWith("..") || path.isAbsolute(relativeToSyncDir)) {
224
- throw new ReviewBundlePlanError(
225
- "INVALID_OUTPUT_DIR",
226
- "output_dir must be inside WRITING_SYNC_DIR.",
227
- { output_dir: resolvedOutputDir, sync_dir: syncDirAbs }
228
- );
229
- }
230
-
231
- return { resolvedOutputDir, relativeToSyncDir };
232
- }
233
-
234
- function resolveProjectRoot(projectId) {
235
- if (projectId.includes("/")) {
236
- const [universeId, projectSlug] = projectId.split("/");
237
- return path.join(syncDir, "universes", universeId, projectSlug);
238
- }
239
- return path.join(syncDir, "projects", projectId);
240
- }
241
-
242
- function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
243
- const slug = slugifyEntityName(name);
244
- const baseDir = projectId
245
- ? path.join(resolveProjectRoot(projectId), "world")
246
- : path.join(syncDir, "universes", universeId, "world");
247
- const bucket = kind === "character" ? "characters" : "places";
248
- return {
249
- slug,
250
- dir: path.join(baseDir, bucket, slug),
251
- };
252
- }
253
-
254
- function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
255
- const prefix = kind === "character" ? "char" : "place";
256
- const idKey = kind === "character" ? "character_id" : "place_id";
257
- const slug = slugifyEntityName(name);
258
- if (!slug) throw new Error("Name must contain at least one alphanumeric character.");
259
-
260
- const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
261
- const prosePath = path.join(dir, "sheet.md");
262
- const metaPath = sidecarPath(prosePath);
263
- const hadProse = fs.existsSync(prosePath);
264
- const hadMeta = fs.existsSync(metaPath);
265
-
266
- let shouldWriteMeta = !hadMeta;
267
- let payload;
268
- const derivedId = `${prefix}-${slug}`;
269
- if (hadMeta) {
270
- let parsedMeta;
271
- try {
272
- parsedMeta = yaml.load(fs.readFileSync(metaPath, "utf8"));
273
- } catch (err) {
274
- throw new Error(
275
- `Existing metadata sidecar is invalid YAML at ${metaPath}: ${err.message}`,
276
- { cause: err }
277
- );
278
- }
279
-
280
- if (parsedMeta != null && (typeof parsedMeta !== "object" || Array.isArray(parsedMeta))) {
281
- throw new Error(`Existing metadata sidecar must be a YAML mapping at ${metaPath}.`);
282
- }
283
-
284
- const existingMeta = parsedMeta ?? {};
285
-
286
- const backfilledId = existingMeta[idKey] ?? derivedId;
287
- const backfilledName = existingMeta.name ?? name;
288
- shouldWriteMeta = existingMeta[idKey] == null || existingMeta.name == null;
289
- payload = shouldWriteMeta
290
- ? {
291
- ...existingMeta,
292
- [idKey]: backfilledId,
293
- name: backfilledName,
294
- }
295
- : existingMeta;
296
- } else {
297
- payload = {
298
- [idKey]: derivedId,
299
- name,
300
- ...(meta ?? {}),
301
- };
302
- }
303
-
304
- fs.mkdirSync(dir, { recursive: true });
305
-
306
- if (!hadProse) {
307
- const defaultSheet = kind === "character"
308
- ? renderCharacterSheetTemplate(name)
309
- : renderPlaceSheetTemplate(name);
310
- const body = notes?.trim() ?? defaultSheet;
311
- fs.writeFileSync(prosePath, `${body}${body ? "\n" : ""}`, "utf8");
312
- }
313
-
314
- if (kind === "character") {
315
- const arcPath = path.join(dir, "arc.md");
316
- if (!fs.existsSync(arcPath)) {
317
- fs.writeFileSync(arcPath, `${renderCharacterArcTemplate(name)}\n`, "utf8");
318
- }
319
- }
320
-
321
- if (shouldWriteMeta) {
322
- fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
323
- }
324
-
325
- syncAll(db, syncDir, { writable: syncDirWritable });
326
-
327
- return {
328
- created: !hadProse && !hadMeta,
329
- id: payload[idKey],
330
- prose_path: prosePath,
331
- meta_path: metaPath,
332
- project_id: projectId ?? null,
333
- universe_id: universeId ?? null,
334
- };
335
- }
336
-
337
- return {
338
- isPathInsideSyncDir,
339
- isPathCandidateInsideSyncDir,
340
- resolveOutputDirWithinSync,
341
- resolveProjectRoot,
342
- resolveWorldEntityDir,
343
- createCanonicalWorldEntity,
344
- };
345
- }
1
+ export * from "./src/core/helpers.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.12.10",
3
+ "version": "2.12.11",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -0,0 +1,356 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import yaml from "js-yaml";
5
+ import { sidecarPath, syncAll } from "../sync/sync.js";
6
+ import {
7
+ slugifyEntityName,
8
+ renderCharacterSheetTemplate,
9
+ renderPlaceSheetTemplate,
10
+ renderCharacterArcTemplate,
11
+ } from "../world/world-entity-templates.js";
12
+
13
+ function createCoreValidationError(code, message, details) {
14
+ const error = new Error(message);
15
+ error.name = "CoreValidationError";
16
+ error.code = code;
17
+ error.details = details;
18
+ return error;
19
+ }
20
+
21
+ export function deriveLoglineFromProse(prose) {
22
+ const compact = prose.replace(/\s+/g, " ").trim();
23
+ if (!compact) return null;
24
+ const sentence = compact.match(/^(.+?[.!?])(?:\s|$)/);
25
+ const candidate = (sentence?.[1] ?? compact).trim();
26
+ if (candidate.length <= 220) return candidate;
27
+ return `${candidate.slice(0, 217).trimEnd()}...`;
28
+ }
29
+
30
+ export function inferCharacterIdsFromProse(dbHandle, prose, projectId) {
31
+ const lower = prose.toLowerCase();
32
+ const rows = dbHandle.prepare(`
33
+ SELECT character_id, name
34
+ FROM characters
35
+ WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
36
+ ORDER BY length(name) DESC
37
+ `).all(projectId, projectId);
38
+
39
+ const found = [];
40
+ for (const row of rows) {
41
+ if (!row.name) continue;
42
+ const words = row.name.toLowerCase().split(/\s+/).filter(Boolean);
43
+ if (words.length && words.every(w => lower.includes(w))) {
44
+ found.push(row.character_id);
45
+ }
46
+ }
47
+ return [...new Set(found)].slice(0, 12);
48
+ }
49
+
50
+ export function readSupportingNotesForEntity(filePath) {
51
+ const ext = path.extname(filePath).toLowerCase();
52
+ const base = path.basename(filePath, ext).toLowerCase();
53
+ if (base !== "sheet") return [];
54
+
55
+ const dir = path.dirname(filePath);
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ } catch {
60
+ return [];
61
+ }
62
+
63
+ return entries
64
+ .filter(entry => entry.isFile())
65
+ .map(entry => entry.name)
66
+ .filter(name => /\.(md|txt)$/i.test(name))
67
+ .filter(name => !/^sheet\.(md|txt)$/i.test(name))
68
+ .sort((a, b) => a.localeCompare(b))
69
+ .map(name => {
70
+ const notePath = path.join(dir, name);
71
+ try {
72
+ const raw = fs.readFileSync(notePath, "utf8");
73
+ const { content } = matter(raw);
74
+ return {
75
+ file_name: name,
76
+ content: content.trim(),
77
+ };
78
+ } catch {
79
+ return null;
80
+ }
81
+ })
82
+ .filter(Boolean)
83
+ .filter(note => note.content);
84
+ }
85
+
86
+ export function readEntityMetadata(filePath) {
87
+ const metaPath = sidecarPath(filePath);
88
+ if (fs.existsSync(metaPath)) {
89
+ try {
90
+ return yaml.load(fs.readFileSync(metaPath, "utf8")) ?? {};
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ try {
97
+ return matter(fs.readFileSync(filePath, "utf8")).data ?? {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ export function resolveBatchTargetScenes(dbHandle, {
104
+ projectId,
105
+ sceneIds,
106
+ part,
107
+ chapter,
108
+ onlyStale,
109
+ }) {
110
+ const projectExists = Boolean(
111
+ dbHandle.prepare(`SELECT 1 FROM projects WHERE project_id = ? LIMIT 1`).get(projectId)
112
+ );
113
+
114
+ if (sceneIds?.length) {
115
+ const placeholders = sceneIds.map(() => "?").join(",");
116
+ const existingRows = dbHandle.prepare(
117
+ `SELECT scene_id FROM scenes WHERE project_id = ? AND scene_id IN (${placeholders})`
118
+ ).all(projectId, ...sceneIds);
119
+ const existing = new Set(existingRows.map(row => row.scene_id));
120
+ const missing = sceneIds.filter(sceneId => !existing.has(sceneId));
121
+ if (missing.length > 0) {
122
+ return { ok: false, code: "NOT_FOUND", message: `Requested scene IDs were not found in project '${projectId}'.`, details: { missing_scene_ids: missing, project_id: projectId } };
123
+ }
124
+ }
125
+
126
+ const conditions = ["project_id = ?"];
127
+ const params = [projectId];
128
+
129
+ if (sceneIds?.length) {
130
+ const placeholders = sceneIds.map(() => "?").join(",");
131
+ conditions.push(`scene_id IN (${placeholders})`);
132
+ params.push(...sceneIds);
133
+ }
134
+ if (part !== undefined) {
135
+ conditions.push("part = ?");
136
+ params.push(part);
137
+ }
138
+ if (chapter !== undefined) {
139
+ conditions.push("chapter = ?");
140
+ params.push(chapter);
141
+ }
142
+ if (onlyStale) {
143
+ conditions.push("metadata_stale = 1");
144
+ }
145
+
146
+ const query = `
147
+ SELECT scene_id, project_id, file_path
148
+ FROM scenes
149
+ WHERE ${conditions.join(" AND ")}
150
+ ORDER BY part, chapter, timeline_position
151
+ `;
152
+
153
+ return {
154
+ ok: true,
155
+ rows: dbHandle.prepare(query).all(...params),
156
+ project_exists: projectExists,
157
+ };
158
+ }
159
+
160
+ export function createHelpers({ syncDir, syncDirReal, syncDirAbs, db, syncDirWritable }) {
161
+ function isPathInsideSyncDir(candidatePath) {
162
+ const resolvedCandidate = path.resolve(candidatePath);
163
+ const canonicalCandidate = (() => {
164
+ try {
165
+ return fs.realpathSync(resolvedCandidate);
166
+ } catch {
167
+ return resolvedCandidate;
168
+ }
169
+ })();
170
+
171
+ const rel = path.relative(syncDirReal, canonicalCandidate);
172
+ return !(rel.startsWith("..") || path.isAbsolute(rel));
173
+ }
174
+
175
+ // Like isPathInsideSyncDir, but works for paths that do not yet exist by
176
+ // walking up to the nearest existing ancestor before canonicalising.
177
+ function isPathCandidateInsideSyncDir(candidatePath) {
178
+ const resolvedCandidate = path.resolve(candidatePath);
179
+
180
+ let existingAncestor = resolvedCandidate;
181
+ while (!fs.existsSync(existingAncestor)) {
182
+ const parent = path.dirname(existingAncestor);
183
+ if (parent === existingAncestor) break;
184
+ existingAncestor = parent;
185
+ }
186
+
187
+ const canonicalBase = (() => {
188
+ try {
189
+ return fs.realpathSync(existingAncestor);
190
+ } catch {
191
+ return existingAncestor;
192
+ }
193
+ })();
194
+
195
+ const canonical = path.resolve(canonicalBase, path.relative(existingAncestor, resolvedCandidate));
196
+ const rel = path.relative(syncDirReal, canonical);
197
+ return !(rel.startsWith("..") || path.isAbsolute(rel));
198
+ }
199
+
200
+ function resolveOutputDirWithinSync(outputDir) {
201
+ let resolvedOutputDir = path.resolve(outputDir);
202
+ let existingAncestor = resolvedOutputDir;
203
+
204
+ while (!fs.existsSync(existingAncestor)) {
205
+ const parentDir = path.dirname(existingAncestor);
206
+ if (parentDir === existingAncestor) {
207
+ throw createCoreValidationError(
208
+ "INVALID_OUTPUT_DIR",
209
+ "output_dir must be inside WRITING_SYNC_DIR.",
210
+ { output_dir: resolvedOutputDir, sync_dir: syncDirAbs }
211
+ );
212
+ }
213
+ existingAncestor = parentDir;
214
+ }
215
+
216
+ let realExistingAncestor;
217
+ try {
218
+ realExistingAncestor = fs.realpathSync.native(existingAncestor);
219
+ } catch (err) {
220
+ throw createCoreValidationError(
221
+ "INVALID_OUTPUT_DIR",
222
+ "output_dir ancestor could not be resolved: path may be inaccessible.",
223
+ {
224
+ output_dir: outputDir,
225
+ existing_ancestor: existingAncestor,
226
+ cause: err instanceof Error ? err.message : String(err),
227
+ }
228
+ );
229
+ }
230
+ const relativeFromAncestor = path.relative(existingAncestor, resolvedOutputDir);
231
+ resolvedOutputDir = path.resolve(realExistingAncestor, relativeFromAncestor);
232
+
233
+ const relativeToSyncDir = path.relative(syncDirReal, resolvedOutputDir);
234
+ if (relativeToSyncDir.startsWith("..") || path.isAbsolute(relativeToSyncDir)) {
235
+ throw createCoreValidationError(
236
+ "INVALID_OUTPUT_DIR",
237
+ "output_dir must be inside WRITING_SYNC_DIR.",
238
+ { output_dir: resolvedOutputDir, sync_dir: syncDirAbs }
239
+ );
240
+ }
241
+
242
+ return { resolvedOutputDir, relativeToSyncDir };
243
+ }
244
+
245
+ function resolveProjectRoot(projectId) {
246
+ if (projectId.includes("/")) {
247
+ const [universeId, projectSlug] = projectId.split("/");
248
+ return path.join(syncDir, "universes", universeId, projectSlug);
249
+ }
250
+ return path.join(syncDir, "projects", projectId);
251
+ }
252
+
253
+ function resolveWorldEntityDir({ kind, projectId, universeId, name }) {
254
+ const slug = slugifyEntityName(name);
255
+ const baseDir = projectId
256
+ ? path.join(resolveProjectRoot(projectId), "world")
257
+ : path.join(syncDir, "universes", universeId, "world");
258
+ const bucket = kind === "character" ? "characters" : "places";
259
+ return {
260
+ slug,
261
+ dir: path.join(baseDir, bucket, slug),
262
+ };
263
+ }
264
+
265
+ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId, meta }) {
266
+ const prefix = kind === "character" ? "char" : "place";
267
+ const idKey = kind === "character" ? "character_id" : "place_id";
268
+ const slug = slugifyEntityName(name);
269
+ if (!slug) throw new Error("Name must contain at least one alphanumeric character.");
270
+
271
+ const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
272
+ const prosePath = path.join(dir, "sheet.md");
273
+ const metaPath = sidecarPath(prosePath);
274
+ const hadProse = fs.existsSync(prosePath);
275
+ const hadMeta = fs.existsSync(metaPath);
276
+
277
+ let shouldWriteMeta = !hadMeta;
278
+ let payload;
279
+ const derivedId = `${prefix}-${slug}`;
280
+ if (hadMeta) {
281
+ let parsedMeta;
282
+ try {
283
+ parsedMeta = yaml.load(fs.readFileSync(metaPath, "utf8"));
284
+ } catch (err) {
285
+ throw new Error(
286
+ `Existing metadata sidecar is invalid YAML at ${metaPath}: ${err.message}`,
287
+ { cause: err }
288
+ );
289
+ }
290
+
291
+ if (parsedMeta != null && (typeof parsedMeta !== "object" || Array.isArray(parsedMeta))) {
292
+ throw new Error(`Existing metadata sidecar must be a YAML mapping at ${metaPath}.`);
293
+ }
294
+
295
+ const existingMeta = parsedMeta ?? {};
296
+
297
+ const backfilledId = existingMeta[idKey] ?? derivedId;
298
+ const backfilledName = existingMeta.name ?? name;
299
+ shouldWriteMeta = existingMeta[idKey] == null || existingMeta.name == null;
300
+ payload = shouldWriteMeta
301
+ ? {
302
+ ...existingMeta,
303
+ [idKey]: backfilledId,
304
+ name: backfilledName,
305
+ }
306
+ : existingMeta;
307
+ } else {
308
+ payload = {
309
+ [idKey]: derivedId,
310
+ name,
311
+ ...(meta ?? {}),
312
+ };
313
+ }
314
+
315
+ fs.mkdirSync(dir, { recursive: true });
316
+
317
+ if (!hadProse) {
318
+ const defaultSheet = kind === "character"
319
+ ? renderCharacterSheetTemplate(name)
320
+ : renderPlaceSheetTemplate(name);
321
+ const body = notes?.trim() ?? defaultSheet;
322
+ fs.writeFileSync(prosePath, `${body}${body ? "\n" : ""}`, "utf8");
323
+ }
324
+
325
+ if (kind === "character") {
326
+ const arcPath = path.join(dir, "arc.md");
327
+ if (!fs.existsSync(arcPath)) {
328
+ fs.writeFileSync(arcPath, `${renderCharacterArcTemplate(name)}\n`, "utf8");
329
+ }
330
+ }
331
+
332
+ if (shouldWriteMeta) {
333
+ fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
334
+ }
335
+
336
+ syncAll(db, syncDir, { writable: syncDirWritable });
337
+
338
+ return {
339
+ created: !hadProse && !hadMeta,
340
+ id: payload[idKey],
341
+ prose_path: prosePath,
342
+ meta_path: metaPath,
343
+ project_id: projectId ?? null,
344
+ universe_id: universeId ?? null,
345
+ };
346
+ }
347
+
348
+ return {
349
+ isPathInsideSyncDir,
350
+ isPathCandidateInsideSyncDir,
351
+ resolveOutputDirWithinSync,
352
+ resolveProjectRoot,
353
+ resolveWorldEntityDir,
354
+ createCanonicalWorldEntity,
355
+ };
356
+ }
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  readSupportingNotesForEntity,
18
18
  readEntityMetadata,
19
19
  resolveBatchTargetScenes,
20
- } from "../helpers.js";
20
+ } from "./core/helpers.js";
21
21
  import { STYLEGUIDE_CONFIG_BASENAME } from "./styleguide/prose-styleguide.js";
22
22
  import { registerSyncTools } from "./tools/sync.js";
23
23
  import { registerSearchTools } from "./tools/search.js";
@@ -79,6 +79,14 @@ export function registerReviewBundleTools(s, {
79
79
  if (error instanceof ReviewBundlePlanError) {
80
80
  return errorResponse(error.code, error.message, error.details);
81
81
  }
82
+ if (
83
+ error &&
84
+ typeof error === "object" &&
85
+ error.name === "CoreValidationError" &&
86
+ typeof error.code === "string"
87
+ ) {
88
+ return errorResponse(error.code, error.message ?? "Request failed.", error.details);
89
+ }
82
90
  return errorResponse(
83
91
  "PREVIEW_FAILED",
84
92
  error instanceof Error ? error.message : "Failed to generate review bundle preview."
@@ -197,6 +205,14 @@ export function registerReviewBundleTools(s, {
197
205
  if (error instanceof ReviewBundlePlanError) {
198
206
  return errorResponse(error.code, error.message, error.details);
199
207
  }
208
+ if (
209
+ error &&
210
+ typeof error === "object" &&
211
+ error.name === "CoreValidationError" &&
212
+ typeof error.code === "string"
213
+ ) {
214
+ return errorResponse(error.code, error.message ?? "Request failed.", error.details);
215
+ }
200
216
  return errorResponse(
201
217
  "CREATE_BUNDLE_FAILED",
202
218
  error instanceof Error ? error.message : "Failed to create review bundle artifacts."