@hanna84/mcp-writing 2.12.4 → 2.12.6

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/async-jobs.js +1 -218
  3. package/async-progress.js +1 -1
  4. package/helpers.js +2 -2
  5. package/importer.js +1 -448
  6. package/index.js +1 -501
  7. package/metadata-lint.js +1 -468
  8. package/package.json +32 -2
  9. package/runtime-diagnostics.js +1 -97
  10. package/scene-character-batch.js +1 -246
  11. package/scene-character-normalization.js +1 -199
  12. package/scripts/async-job-runner.mjs +3 -3
  13. package/scripts/generate-tool-docs.mjs +21 -3
  14. package/scripts/import.js +1 -1
  15. package/scripts/lint-metadata.mjs +1 -1
  16. package/scripts/manual-scrivener-realtest.mjs +3 -3
  17. package/scripts/merge-scrivx.js +2 -2
  18. package/scripts/new-world-entity.js +2 -2
  19. package/scripts/normalize-scene-characters.mjs +3 -3
  20. package/scripts/profile-review-bundles.mjs +1 -1
  21. package/scrivener-direct.js +1 -843
  22. package/src/index.js +502 -0
  23. package/src/runtime/async-jobs.js +218 -0
  24. package/src/runtime/async-progress.js +1 -0
  25. package/src/runtime/runtime-diagnostics.js +97 -0
  26. package/src/sync/importer.js +448 -0
  27. package/src/sync/metadata-lint.js +468 -0
  28. package/src/sync/scene-character-batch.js +246 -0
  29. package/src/sync/scene-character-normalization.js +199 -0
  30. package/src/sync/scrivener-direct.js +843 -0
  31. package/src/sync/sync.js +755 -0
  32. package/src/world/world-entity-templates.js +116 -0
  33. package/sync.js +1 -755
  34. package/tools/editing.js +1 -1
  35. package/tools/metadata.js +2 -2
  36. package/tools/review-bundles.js +1 -1
  37. package/tools/styleguide.js +1 -1
  38. package/tools/sync.js +2 -2
  39. package/world-entity-templates.js +1 -116
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
7
+ import { checkpointJobCreate, checkpointJobFinish, pruneJobCheckpoints } from "../../db.js";
8
+
9
+ export function readJsonIfExists(filePath) {
10
+ if (!filePath || !fs.existsSync(filePath)) return null;
11
+ try {
12
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ export function createAsyncJobManager({ db, asyncJobs, ttlMs, runnerDir }) {
19
+ function pruneAsyncJobs() {
20
+ const now = Date.now();
21
+ let anyPruned = false;
22
+ for (const [id, job] of asyncJobs.entries()) {
23
+ if (!job.finishedAt) continue;
24
+ if (now - Date.parse(job.finishedAt) > ttlMs) {
25
+ try {
26
+ if (job.tmpDir && fs.existsSync(job.tmpDir)) {
27
+ fs.rmSync(job.tmpDir, { recursive: true, force: true });
28
+ } else {
29
+ if (job.requestPath && fs.existsSync(job.requestPath)) fs.unlinkSync(job.requestPath);
30
+ if (job.resultPath && fs.existsSync(job.resultPath)) fs.unlinkSync(job.resultPath);
31
+ }
32
+ } catch {
33
+ // best effort cleanup
34
+ }
35
+ asyncJobs.delete(id);
36
+ anyPruned = true;
37
+ }
38
+ }
39
+ if (anyPruned) {
40
+ try { pruneJobCheckpoints(db, ttlMs); } catch { /* best effort */ }
41
+ }
42
+ }
43
+
44
+ function toPublicJob(job, includeResult = true) {
45
+ return {
46
+ job_id: job.id,
47
+ kind: job.kind,
48
+ status: job.status,
49
+ created_at: job.createdAt,
50
+ started_at: job.startedAt,
51
+ finished_at: job.finishedAt,
52
+ pid: job.pid,
53
+ error: job.error,
54
+ ...(job.progress ? { progress: job.progress } : {}),
55
+ ...(includeResult ? { result: job.result } : {}),
56
+ };
57
+ }
58
+
59
+ function startAsyncJob({ kind, requestPayload, onComplete }) {
60
+ pruneAsyncJobs();
61
+
62
+ const id = randomUUID();
63
+ const tmpPrefix = path.join(os.tmpdir(), "mcp-writing-job-");
64
+ const tmpDir = fs.mkdtempSync(tmpPrefix);
65
+ const requestPath = path.join(tmpDir, `${id}.request.json`);
66
+ const resultPath = path.join(tmpDir, `${id}.result.json`);
67
+
68
+ fs.writeFileSync(requestPath, JSON.stringify(requestPayload, null, 2), "utf8");
69
+
70
+ const runnerPath = path.join(runnerDir, "scripts", "async-job-runner.mjs");
71
+ const child = spawn(
72
+ process.execPath,
73
+ ["--experimental-sqlite", runnerPath, requestPath, resultPath],
74
+ {
75
+ env: process.env,
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ }
78
+ );
79
+
80
+ const job = {
81
+ id,
82
+ kind,
83
+ status: "running",
84
+ createdAt: new Date().toISOString(),
85
+ startedAt: new Date().toISOString(),
86
+ finishedAt: null,
87
+ pid: child.pid,
88
+ tmpDir,
89
+ requestPath,
90
+ resultPath,
91
+ result: null,
92
+ progress: null,
93
+ error: null,
94
+ onComplete,
95
+ child,
96
+ };
97
+ asyncJobs.set(id, job);
98
+ try {
99
+ checkpointJobCreate(db, job);
100
+ } catch (err) {
101
+ process.stderr.write(`[mcp-writing] WARNING: failed to checkpoint job ${id}: ${err.message}\n`);
102
+ }
103
+
104
+ let stdoutBuffer = "";
105
+ child.stdout.on("data", (chunk) => {
106
+ stdoutBuffer += chunk.toString("utf8");
107
+ const lines = stdoutBuffer.split("\n");
108
+ stdoutBuffer = lines.pop() ?? "";
109
+
110
+ for (const line of lines) {
111
+ const trimmed = line.trim();
112
+ if (!trimmed.startsWith(ASYNC_PROGRESS_PREFIX)) continue;
113
+ const payload = trimmed.slice(ASYNC_PROGRESS_PREFIX.length);
114
+ try {
115
+ const progress = JSON.parse(payload);
116
+ if (progress && typeof progress === "object") {
117
+ const nextProgress = {
118
+ total_scenes: Number(progress.total_scenes ?? 0),
119
+ processed_scenes: Number(progress.processed_scenes ?? 0),
120
+ scenes_changed: Number(progress.scenes_changed ?? 0),
121
+ failed_scenes: Number(progress.failed_scenes ?? 0),
122
+ };
123
+ job.progress = nextProgress;
124
+ }
125
+ } catch {
126
+ // Ignore malformed progress lines; they are best-effort telemetry.
127
+ }
128
+ }
129
+ });
130
+ child.stderr.on("data", () => {
131
+ // avoid crashing on stderr backpressure for noisy runs
132
+ });
133
+
134
+ child.on("error", (error) => {
135
+ if (job.status === "cancelling") {
136
+ job.status = "cancelled";
137
+ job.error = error.message;
138
+ job.finishedAt = new Date().toISOString();
139
+ try { checkpointJobFinish(db, job); } catch { /* best effort */ }
140
+ pruneAsyncJobs();
141
+ return;
142
+ }
143
+ job.status = "failed";
144
+ job.error = error.message;
145
+ job.finishedAt = new Date().toISOString();
146
+ try { checkpointJobFinish(db, job); } catch { /* best effort */ }
147
+ pruneAsyncJobs();
148
+ });
149
+
150
+ child.on("exit", (code, signal) => {
151
+ const payload = readJsonIfExists(resultPath);
152
+ const successful = payload?.ok === true;
153
+ const cancelledBySignal = signal === "SIGTERM" || signal === "SIGKILL";
154
+ const cancelledByPayload = payload?.cancelled === true;
155
+
156
+ job.finishedAt = new Date().toISOString();
157
+ job.result = payload;
158
+
159
+ const hasProgressFields = payload && (
160
+ payload.total_scenes !== undefined
161
+ || payload.processed_scenes !== undefined
162
+ || payload.scenes_changed !== undefined
163
+ || payload.failed_scenes !== undefined
164
+ );
165
+
166
+ if (payload && payload.ok === true && hasProgressFields) {
167
+ job.progress = {
168
+ total_scenes: Number(payload.total_scenes ?? job.progress?.total_scenes ?? 0),
169
+ processed_scenes: Number(payload.processed_scenes ?? job.progress?.processed_scenes ?? 0),
170
+ scenes_changed: Number(payload.scenes_changed ?? job.progress?.scenes_changed ?? 0),
171
+ failed_scenes: Number(payload.failed_scenes ?? job.progress?.failed_scenes ?? 0),
172
+ };
173
+ }
174
+
175
+ if (job.status === "cancelling") {
176
+ if (cancelledByPayload) {
177
+ job.status = "cancelled";
178
+ job.error = "Async job cancelled after returning partial results.";
179
+ } else if (successful && !cancelledBySignal) {
180
+ // Race: cancellation was requested as work completed successfully.
181
+ job.status = "completed";
182
+ } else {
183
+ job.status = "cancelled";
184
+ job.error = cancelledBySignal
185
+ ? `Async job cancelled by signal ${signal}.`
186
+ : payload?.error?.message ?? payload?.error ?? "Async job cancelled.";
187
+ try { checkpointJobFinish(db, job); } catch { /* best effort */ }
188
+ pruneAsyncJobs();
189
+ return;
190
+ }
191
+ } else {
192
+ job.status = successful ? "completed" : "failed";
193
+ if (!successful) {
194
+ job.error = payload?.error?.message
195
+ ?? payload?.error
196
+ ?? (signal
197
+ ? `Async job exited due to signal ${signal}.`
198
+ : `Async job exited with code ${code}.`);
199
+ }
200
+ }
201
+
202
+ if (job.status === "completed" && typeof job.onComplete === "function") {
203
+ try {
204
+ job.onComplete(job);
205
+ } catch (error) {
206
+ job.status = "failed";
207
+ job.error = error instanceof Error ? error.message : String(error);
208
+ }
209
+ }
210
+ try { checkpointJobFinish(db, job); } catch { /* best effort */ }
211
+ pruneAsyncJobs();
212
+ });
213
+
214
+ return job;
215
+ }
216
+
217
+ return { pruneAsyncJobs, toPublicJob, startAsyncJob };
218
+ }
@@ -0,0 +1 @@
1
+ export const ASYNC_PROGRESS_PREFIX = "__MCP_ASYNC_PROGRESS__ ";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * getRuntimeDiagnostics
3
+ *
4
+ * Inspects the startup environment and returns { warnings, recommendations }.
5
+ * All inputs are passed explicitly so this module has no side effects and
6
+ * is straightforward to test.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string} opts.ownershipGuardModeRaw Raw env value before normalisation
10
+ * @param {string} opts.ownershipGuardMode Normalised value ("warn" | "fail")
11
+ * @param {string} opts.ownershipGuardModeRawDisplay JSON.stringify of the raw value
12
+ * @param {boolean} opts.syncDirWritable
13
+ * @param {string} opts.syncDirAbs Resolved absolute path shown in messages
14
+ * @param {object} opts.syncOwnershipDiagnostics Result of getSyncOwnershipDiagnostics()
15
+ * @param {boolean} opts.gitAvailable
16
+ * @param {boolean} opts.gitEnabled
17
+ * @returns {{ warnings: string[], recommendations: string[] }}
18
+ */
19
+ export function getRuntimeDiagnostics({
20
+ ownershipGuardModeRaw,
21
+ ownershipGuardMode,
22
+ ownershipGuardModeRawDisplay,
23
+ syncDirWritable,
24
+ syncDirAbs,
25
+ syncOwnershipDiagnostics,
26
+ gitAvailable,
27
+ gitEnabled,
28
+ }) {
29
+ const warnings = [];
30
+ const recommendations = [];
31
+
32
+ if (ownershipGuardModeRaw !== ownershipGuardMode) {
33
+ warnings.push(
34
+ `OWNERSHIP_GUARD_MODE_INVALID: Unsupported OWNERSHIP_GUARD_MODE=${ownershipGuardModeRawDisplay}. Falling back to 'warn'.`
35
+ );
36
+ recommendations.push("Set OWNERSHIP_GUARD_MODE to either 'warn' or 'fail'.");
37
+ }
38
+
39
+ if (syncOwnershipDiagnostics.runtime_uid_override_ignored) {
40
+ warnings.push("RUNTIME_UID_OVERRIDE_IGNORED: RUNTIME_UID_OVERRIDE is ignored unless NODE_ENV=test or ALLOW_RUNTIME_UID_OVERRIDE=1.");
41
+ recommendations.push("Avoid RUNTIME_UID_OVERRIDE in production runtime environments.");
42
+ }
43
+
44
+ if (syncOwnershipDiagnostics.runtime_uid_override_invalid) {
45
+ warnings.push("RUNTIME_UID_OVERRIDE_INVALID: RUNTIME_UID_OVERRIDE must be a non-negative integer when enabled.");
46
+ recommendations.push("Set RUNTIME_UID_OVERRIDE to a non-negative integer, or unset it.");
47
+ }
48
+
49
+ if (!syncDirWritable) {
50
+ warnings.push("SYNC_DIR_READ_ONLY: sync dir is read-only; metadata write-back and prose editing tools are unavailable.");
51
+ recommendations.push("Mount WRITING_SYNC_DIR with write access (avoid read-only mounts like ':ro').");
52
+ recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
53
+ }
54
+
55
+ if (syncOwnershipDiagnostics.supported && syncOwnershipDiagnostics.non_runtime_owned_paths > 0) {
56
+ warnings.push(
57
+ `OWNERSHIP_MISMATCH: ${syncOwnershipDiagnostics.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${syncOwnershipDiagnostics.runtime_uid}.`
58
+ );
59
+ recommendations.push(
60
+ `Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${syncDirAbs}"`
61
+ );
62
+ recommendations.push(
63
+ "For Docker/OpenClaw, run container as host user (compose: user: \"${OPENCLAW_UID:-1000}:${OPENCLAW_GID:-1000}\")."
64
+ );
65
+ }
66
+
67
+ if (ownershipGuardMode === "fail" && syncOwnershipDiagnostics.runtime_uid === 0) {
68
+ warnings.push(
69
+ "OWNERSHIP_GUARD_SKIPPED_FOR_ROOT: OWNERSHIP_GUARD_MODE=fail is skipped because runtime UID is 0 (root)."
70
+ );
71
+ recommendations.push("Prefer running as a non-root host-mapped UID/GID to make ownership guard checks meaningful.");
72
+ }
73
+
74
+ if (syncOwnershipDiagnostics.supported && syncOwnershipDiagnostics.root_owned_paths > 0) {
75
+ warnings.push(
76
+ `ROOT_OWNED_PATHS: ${syncOwnershipDiagnostics.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
77
+ );
78
+ }
79
+
80
+ if (!gitAvailable) {
81
+ warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
82
+ recommendations.push("Install git in the runtime image/environment.");
83
+ }
84
+
85
+ if (gitAvailable && syncDirWritable && !gitEnabled) {
86
+ warnings.push("GIT_DISABLED: git is available but repository snapshot tools are not active.");
87
+ recommendations.push("Ensure WRITING_SYNC_DIR points to a writable git repository root, or allow mcp-writing to initialize one.");
88
+ }
89
+
90
+ if (gitAvailable && !syncDirWritable) {
91
+ recommendations.push("If git reports 'dubious ownership' for mounted repos, add: git config --system --add safe.directory /sync");
92
+ }
93
+
94
+ recommendations.push("If indexing finds many files without scene_id, run scripts/import.js first for Scrivener Draft exports, then run sync.");
95
+
96
+ return { warnings, recommendations };
97
+ }