@hanna84/mcp-writing 2.9.0 → 2.9.4
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 +40 -0
- package/git.js +4 -3
- package/index.js +54 -1108
- package/package.json +6 -5
- package/scripts/generate-tool-docs.mjs +20 -5
- package/sync.js +4 -0
- package/tools/search.js +528 -0
- package/tools/sync.js +612 -0
package/index.js
CHANGED
|
@@ -12,10 +12,10 @@ import matter from "gray-matter";
|
|
|
12
12
|
import yaml from "js-yaml";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { openDb } from "./db.js";
|
|
15
|
-
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
15
|
+
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath, isStructuralProjectId } from "./sync.js";
|
|
16
16
|
import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit, getHeadCommitHash } from "./git.js";
|
|
17
17
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
18
|
-
import {
|
|
18
|
+
import { validateProjectId, validateUniverseId } from "./importer.js";
|
|
19
19
|
import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
|
|
20
20
|
import {
|
|
21
21
|
STYLEGUIDE_CONFIG_BASENAME,
|
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
buildReviewBundlePlan,
|
|
44
44
|
createReviewBundleArtifacts,
|
|
45
45
|
} from "./review-bundles.js";
|
|
46
|
+
import { registerSyncTools } from "./tools/sync.js";
|
|
47
|
+
import { registerSearchTools } from "./tools/search.js";
|
|
46
48
|
|
|
47
49
|
const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
|
|
48
50
|
const DB_PATH = process.env.DB_PATH ?? "./writing.db";
|
|
@@ -700,9 +702,8 @@ if (GIT_AVAILABLE && SYNC_DIR_WRITABLE) {
|
|
|
700
702
|
|
|
701
703
|
// In-memory storage for pending edit proposals (Phase 3)
|
|
702
704
|
const pendingProposals = new Map();
|
|
703
|
-
let nextProposalId = 1;
|
|
704
705
|
function generateProposalId() {
|
|
705
|
-
return `proposal-${
|
|
706
|
+
return `proposal-${randomUUID()}`;
|
|
706
707
|
}
|
|
707
708
|
|
|
708
709
|
function getRuntimeDiagnostics() {
|
|
@@ -866,7 +867,7 @@ const WORKFLOW_CATALOGUE = [
|
|
|
866
867
|
steps: [
|
|
867
868
|
{ tool: "describe_workflows", note: "Check context.scene_count; use that value as max_scenes in the next call." },
|
|
868
869
|
{ tool: "bootstrap_prose_styleguide_config", note: "Detect dominant conventions. Confirm suggestions with the user before applying." },
|
|
869
|
-
{ tool: "setup_prose_styleguide_config", note: "
|
|
870
|
+
{ tool: "setup_prose_styleguide_config", note: "Only if ALL context.styleguide_exists fields are false — a config at any scope is sufficient. Create at project_root scope (requires project_id and language e.g. 'english_us'), or sync_root if no project_id is known." },
|
|
870
871
|
{ tool: "update_prose_styleguide_config", note: "Apply the fields accepted from bootstrap suggestions." },
|
|
871
872
|
],
|
|
872
873
|
},
|
|
@@ -960,7 +961,15 @@ function createMcpServer() {
|
|
|
960
961
|
const projectRow = db.prepare(
|
|
961
962
|
`SELECT project_id FROM scenes GROUP BY project_id ORDER BY COUNT(*) DESC, project_id ASC LIMIT 1`
|
|
962
963
|
).get();
|
|
963
|
-
|
|
964
|
+
// Suppress structural-dir names (e.g. "scenes") that appear when SYNC_DIR points at the
|
|
965
|
+
// project directory itself rather than the universe root. They are path artifacts, not
|
|
966
|
+
// real project identifiers. Only suppress when no real project directory exists at that
|
|
967
|
+
// path, so a project intentionally named "scenes" (though inadvisable) is still honoured.
|
|
968
|
+
const rawProjectId = projectRow?.project_id ?? null;
|
|
969
|
+
const rawProjectRootPath = rawProjectId ? resolveProjectRoot(rawProjectId) : null;
|
|
970
|
+
const project_id = (
|
|
971
|
+
isStructuralProjectId(rawProjectId) && !fs.existsSync(rawProjectRootPath)
|
|
972
|
+
) ? null : rawProjectId;
|
|
964
973
|
|
|
965
974
|
const sceneCountRow = db.prepare(`SELECT COUNT(*) as count FROM scenes`).get();
|
|
966
975
|
const scene_count = sceneCountRow?.count ?? 0;
|
|
@@ -974,6 +983,10 @@ function createMcpServer() {
|
|
|
974
983
|
? path.join(SYNC_DIR, "universes", universeSegment, STYLEGUIDE_CONFIG_BASENAME)
|
|
975
984
|
: null;
|
|
976
985
|
|
|
986
|
+
const syncRootExists = fs.existsSync(syncRootConfigPath);
|
|
987
|
+
const universeRootExists = universeRootConfigPath !== null && fs.existsSync(universeRootConfigPath);
|
|
988
|
+
const projectRootExists = projectRootConfigPath !== null && fs.existsSync(projectRootConfigPath);
|
|
989
|
+
|
|
977
990
|
return jsonResponse({
|
|
978
991
|
ok: true,
|
|
979
992
|
context: {
|
|
@@ -981,9 +994,9 @@ function createMcpServer() {
|
|
|
981
994
|
scene_count,
|
|
982
995
|
sync_dir: SYNC_DIR_ABS,
|
|
983
996
|
styleguide_exists: {
|
|
984
|
-
sync_root:
|
|
985
|
-
universe_root:
|
|
986
|
-
project_root:
|
|
997
|
+
sync_root: syncRootExists,
|
|
998
|
+
universe_root: universeRootExists,
|
|
999
|
+
project_root: projectRootExists,
|
|
987
1000
|
},
|
|
988
1001
|
git_available: GIT_AVAILABLE,
|
|
989
1002
|
pending_proposals: pendingProposals.size,
|
|
@@ -994,529 +1007,43 @@ function createMcpServer() {
|
|
|
994
1007
|
"If a tool returns a next_step field (in a success or error response), follow it before trying anything else.",
|
|
995
1008
|
"Use find_scenes without filters to discover what project_ids are indexed.",
|
|
996
1009
|
"When calling bootstrap_prose_styleguide_config or check_prose_styleguide_drift, set max_scenes to context.scene_count to avoid the default limit.",
|
|
1010
|
+
"Styleguide tools resolve config in priority order: project_root > universe_root > sync_root. If any styleguide_exists field is true, a config exists and styleguide tools will work — do not run setup_prose_styleguide_config unless ALL styleguide_exists fields are false.",
|
|
997
1011
|
],
|
|
998
1012
|
});
|
|
999
1013
|
}
|
|
1000
1014
|
);
|
|
1001
1015
|
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
if (!projectIdCheck.ok) {
|
|
1034
|
-
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
const ignorePatternCheck = validateRegexPatterns(ignore_patterns);
|
|
1039
|
-
if (!ignorePatternCheck.ok) {
|
|
1040
|
-
return errorResponse(
|
|
1041
|
-
"INVALID_IGNORE_PATTERN",
|
|
1042
|
-
`Invalid ignore pattern '${ignorePatternCheck.pattern}': ${ignorePatternCheck.reason}`,
|
|
1043
|
-
{
|
|
1044
|
-
source_dir,
|
|
1045
|
-
sync_dir: SYNC_DIR_ABS,
|
|
1046
|
-
project_id: project_id ?? null,
|
|
1047
|
-
pattern: ignorePatternCheck.pattern,
|
|
1048
|
-
}
|
|
1049
|
-
);
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (!dry_run && !SYNC_DIR_WRITABLE) {
|
|
1053
|
-
return errorResponse(
|
|
1054
|
-
"SYNC_DIR_NOT_WRITABLE",
|
|
1055
|
-
"Cannot import because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1056
|
-
{ sync_dir: SYNC_DIR_ABS }
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
let importResult;
|
|
1061
|
-
try {
|
|
1062
|
-
importResult = importScrivenerSync({
|
|
1063
|
-
scrivenerDir: source_dir,
|
|
1064
|
-
mcpSyncDir: SYNC_DIR,
|
|
1065
|
-
projectId: project_id,
|
|
1066
|
-
dryRun: Boolean(dry_run) || preflight,
|
|
1067
|
-
preflight: Boolean(preflight),
|
|
1068
|
-
ignorePatterns: ignore_patterns,
|
|
1069
|
-
});
|
|
1070
|
-
} catch (error) {
|
|
1071
|
-
if (error && typeof error === "object" && error.code === "INVALID_IGNORE_PATTERN") {
|
|
1072
|
-
return errorResponse(
|
|
1073
|
-
"INVALID_IGNORE_PATTERN",
|
|
1074
|
-
error instanceof Error ? error.message : "Invalid ignore pattern.",
|
|
1075
|
-
{
|
|
1076
|
-
source_dir,
|
|
1077
|
-
sync_dir: SYNC_DIR_ABS,
|
|
1078
|
-
project_id: project_id ?? null,
|
|
1079
|
-
pattern: error.pattern ?? null,
|
|
1080
|
-
}
|
|
1081
|
-
);
|
|
1082
|
-
}
|
|
1083
|
-
return errorResponse(
|
|
1084
|
-
"IMPORT_FAILED",
|
|
1085
|
-
error instanceof Error ? error.message : "Import failed.",
|
|
1086
|
-
{
|
|
1087
|
-
source_dir,
|
|
1088
|
-
sync_dir: SYNC_DIR_ABS,
|
|
1089
|
-
project_id: project_id ?? null,
|
|
1090
|
-
}
|
|
1091
|
-
);
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
let syncResult = null;
|
|
1095
|
-
if (!dry_run && !preflight && auto_sync) {
|
|
1096
|
-
syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
return jsonResponse({
|
|
1100
|
-
ok: true,
|
|
1101
|
-
import: {
|
|
1102
|
-
source_dir: importResult.scrivenerDir,
|
|
1103
|
-
sync_dir: importResult.mcpSyncDir,
|
|
1104
|
-
scenes_dir: importResult.scenesDir,
|
|
1105
|
-
project_id: importResult.projectId,
|
|
1106
|
-
preflight: importResult.preflight,
|
|
1107
|
-
source_files: importResult.sourceFiles,
|
|
1108
|
-
ignored_files: importResult.ignoredFiles,
|
|
1109
|
-
...(importResult.preflight ? {
|
|
1110
|
-
files_to_process: importResult.filesToProcess,
|
|
1111
|
-
file_previews: importResult.filePreviews,
|
|
1112
|
-
existing_sidecars: importResult.existingSidecars,
|
|
1113
|
-
} : {}),
|
|
1114
|
-
created: importResult.created,
|
|
1115
|
-
existing: importResult.existing,
|
|
1116
|
-
skipped: importResult.skipped,
|
|
1117
|
-
beat_markers_seen: importResult.beatMarkersSeen,
|
|
1118
|
-
dry_run: importResult.dryRun,
|
|
1119
|
-
},
|
|
1120
|
-
sync: syncResult
|
|
1121
|
-
? {
|
|
1122
|
-
indexed: syncResult.indexed,
|
|
1123
|
-
stale_marked: syncResult.staleMarked,
|
|
1124
|
-
sidecars_migrated: syncResult.sidecarsMigrated,
|
|
1125
|
-
skipped: syncResult.skipped,
|
|
1126
|
-
warning_summary: syncResult.warningSummary,
|
|
1127
|
-
}
|
|
1128
|
-
: null,
|
|
1129
|
-
next_step: preflight
|
|
1130
|
-
? "Preflight complete. Review file_previews and ignored_files, then re-run without preflight=true."
|
|
1131
|
-
: dry_run
|
|
1132
|
-
? "Dry run complete. Re-run with dry_run=false to write files."
|
|
1133
|
-
: auto_sync
|
|
1134
|
-
? "Import and sync complete."
|
|
1135
|
-
: "Import complete. Run sync() to index imported scenes.",
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
);
|
|
1139
|
-
|
|
1140
|
-
// ---- async import/merge jobs --------------------------------------------
|
|
1141
|
-
s.tool(
|
|
1142
|
-
"import_scrivener_sync_async",
|
|
1143
|
-
"[STABLE] Start an asynchronous Scrivener External Folder Sync import job. This is the recommended default import path when the sync tree is large. Returns immediately with a job_id to poll via get_async_job_status.",
|
|
1144
|
-
{
|
|
1145
|
-
source_dir: z.string().describe("Path to Scrivener external sync folder (the folder that contains Draft/, or Draft/ itself)."),
|
|
1146
|
-
project_id: z.string().optional().describe("Project ID override (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1147
|
-
dry_run: z.boolean().optional().describe("If true, reports planned writes without changing files."),
|
|
1148
|
-
auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async import finishes."),
|
|
1149
|
-
preflight: z.boolean().optional().describe("If true, returns a list of files that would be processed without doing any work."),
|
|
1150
|
-
ignore_patterns: z.array(z.string()).optional().describe("Array of regex patterns matched against filenames. Files matching any pattern are excluded from import."),
|
|
1151
|
-
},
|
|
1152
|
-
async ({ source_dir, project_id, dry_run = false, auto_sync = false, preflight = false, ignore_patterns = [] }) => {
|
|
1153
|
-
if (project_id !== undefined) {
|
|
1154
|
-
const projectIdCheck = validateProjectId(project_id);
|
|
1155
|
-
if (!projectIdCheck.ok) {
|
|
1156
|
-
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
const ignorePatternCheck = validateRegexPatterns(ignore_patterns);
|
|
1161
|
-
if (!ignorePatternCheck.ok) {
|
|
1162
|
-
return errorResponse(
|
|
1163
|
-
"INVALID_IGNORE_PATTERN",
|
|
1164
|
-
`Invalid ignore pattern '${ignorePatternCheck.pattern}': ${ignorePatternCheck.reason}`,
|
|
1165
|
-
{
|
|
1166
|
-
source_dir,
|
|
1167
|
-
sync_dir: SYNC_DIR_ABS,
|
|
1168
|
-
project_id: project_id ?? null,
|
|
1169
|
-
pattern: ignorePatternCheck.pattern,
|
|
1170
|
-
}
|
|
1171
|
-
);
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
if (!dry_run && !preflight && !SYNC_DIR_WRITABLE) {
|
|
1175
|
-
return errorResponse(
|
|
1176
|
-
"SYNC_DIR_NOT_WRITABLE",
|
|
1177
|
-
"Cannot import because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1178
|
-
{ sync_dir: SYNC_DIR_ABS }
|
|
1179
|
-
);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
const job = startAsyncJob({
|
|
1183
|
-
kind: "import_scrivener_sync",
|
|
1184
|
-
requestPayload: {
|
|
1185
|
-
kind: "import_scrivener_sync",
|
|
1186
|
-
args: {
|
|
1187
|
-
source_dir,
|
|
1188
|
-
project_id,
|
|
1189
|
-
dry_run: Boolean(dry_run),
|
|
1190
|
-
preflight: Boolean(preflight),
|
|
1191
|
-
ignore_patterns,
|
|
1192
|
-
},
|
|
1193
|
-
context: {
|
|
1194
|
-
sync_dir: SYNC_DIR,
|
|
1195
|
-
},
|
|
1196
|
-
},
|
|
1197
|
-
onComplete: (completedJob) => {
|
|
1198
|
-
if (!auto_sync || dry_run || preflight || completedJob.status !== "completed") return;
|
|
1199
|
-
const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1200
|
-
if (completedJob.result && completedJob.result.ok) {
|
|
1201
|
-
completedJob.result.sync = {
|
|
1202
|
-
indexed: syncResult.indexed,
|
|
1203
|
-
stale_marked: syncResult.staleMarked,
|
|
1204
|
-
sidecars_migrated: syncResult.sidecarsMigrated,
|
|
1205
|
-
skipped: syncResult.skipped,
|
|
1206
|
-
warning_summary: syncResult.warningSummary,
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
},
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
return jsonResponse({
|
|
1213
|
-
ok: true,
|
|
1214
|
-
async: true,
|
|
1215
|
-
job: toPublicJob(job, false),
|
|
1216
|
-
next_step: "Call get_async_job_status with job_id until status is 'completed' or 'failed'.",
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
s.tool(
|
|
1222
|
-
"merge_scrivener_project_beta",
|
|
1223
|
-
"Merge metadata directly from a Scrivener .scriv project into existing scene sidecars by starting a background job. This path is opt-in and requires sidecars to already exist (for example, from import_scrivener_sync). Returns immediately with a job_id to poll via get_async_job_status.",
|
|
1224
|
-
{
|
|
1225
|
-
source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
|
|
1226
|
-
project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1227
|
-
scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id."),
|
|
1228
|
-
dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
|
|
1229
|
-
auto_sync: z.boolean().optional().describe("If true, runs sync() after a non-dry-run async merge finishes."),
|
|
1230
|
-
organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies. Chapter metadata is always extracted to sidecars."),
|
|
1231
|
-
},
|
|
1232
|
-
async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = false, organize_by_chapters = false }) => {
|
|
1233
|
-
if (project_id !== undefined) {
|
|
1234
|
-
const projectIdCheck = validateProjectId(project_id);
|
|
1235
|
-
if (!projectIdCheck.ok) {
|
|
1236
|
-
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
if (!dry_run && !SYNC_DIR_WRITABLE) {
|
|
1241
|
-
return errorResponse(
|
|
1242
|
-
"SYNC_DIR_NOT_WRITABLE",
|
|
1243
|
-
"Cannot merge Scrivener metadata because WRITING_SYNC_DIR is not writable in this runtime.",
|
|
1244
|
-
{ sync_dir: SYNC_DIR_ABS }
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
const resolvedScenesDir = scenes_dir
|
|
1249
|
-
?? (project_id ? path.join(resolveProjectRoot(project_id), "scenes") : undefined);
|
|
1250
|
-
const normalizedScenesDir = resolvedScenesDir ? path.resolve(resolvedScenesDir) : undefined;
|
|
1251
|
-
|
|
1252
|
-
if (normalizedScenesDir) {
|
|
1253
|
-
if (!isPathInsideSyncDir(normalizedScenesDir)) {
|
|
1254
|
-
return errorResponse(
|
|
1255
|
-
"INVALID_SCENES_DIR",
|
|
1256
|
-
"scenes_dir must be inside WRITING_SYNC_DIR.",
|
|
1257
|
-
{ scenes_dir: normalizedScenesDir, sync_dir: SYNC_DIR_ABS, sync_dir_real: SYNC_DIR_REAL }
|
|
1258
|
-
);
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const job = startAsyncJob({
|
|
1263
|
-
kind: "merge_scrivener_project_beta",
|
|
1264
|
-
requestPayload: {
|
|
1265
|
-
kind: "merge_scrivener_project_beta",
|
|
1266
|
-
args: {
|
|
1267
|
-
source_project_dir,
|
|
1268
|
-
project_id,
|
|
1269
|
-
scenes_dir: normalizedScenesDir,
|
|
1270
|
-
dry_run: Boolean(dry_run),
|
|
1271
|
-
organize_by_chapters: Boolean(organize_by_chapters),
|
|
1272
|
-
},
|
|
1273
|
-
context: {
|
|
1274
|
-
sync_dir: SYNC_DIR,
|
|
1275
|
-
},
|
|
1276
|
-
},
|
|
1277
|
-
onComplete: (completedJob) => {
|
|
1278
|
-
if (!auto_sync || dry_run || completedJob.status !== "completed") return;
|
|
1279
|
-
const syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1280
|
-
if (completedJob.result && completedJob.result.ok) {
|
|
1281
|
-
completedJob.result.sync = {
|
|
1282
|
-
indexed: syncResult.indexed,
|
|
1283
|
-
stale_marked: syncResult.staleMarked,
|
|
1284
|
-
sidecars_migrated: syncResult.sidecarsMigrated,
|
|
1285
|
-
skipped: syncResult.skipped,
|
|
1286
|
-
warning_summary: syncResult.warningSummary,
|
|
1287
|
-
};
|
|
1288
|
-
}
|
|
1289
|
-
},
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1292
|
-
return jsonResponse({
|
|
1293
|
-
ok: true,
|
|
1294
|
-
async: true,
|
|
1295
|
-
job: toPublicJob(job, false),
|
|
1296
|
-
next_step: "Call get_async_job_status with job_id until status is 'completed' or 'failed'.",
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
);
|
|
1300
|
-
|
|
1301
|
-
s.tool(
|
|
1302
|
-
"enrich_scene_characters_batch",
|
|
1303
|
-
"Start an asynchronous batch job that infers scene character mentions and updates scene metadata links. Version 1 uses canonical character names only (no aliases). Defaults to dry_run=true.",
|
|
1304
|
-
{
|
|
1305
|
-
project_id: z.string().describe("Project ID (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
|
|
1306
|
-
scene_ids: z.array(z.string()).optional().describe("Optional allowlist of scene IDs to process before other filters are applied."),
|
|
1307
|
-
part: z.number().int().optional().describe("Optional part number filter."),
|
|
1308
|
-
chapter: z.number().int().optional().describe("Optional chapter number filter."),
|
|
1309
|
-
only_stale: z.boolean().optional().describe("If true, only process scenes currently marked metadata_stale."),
|
|
1310
|
-
dry_run: z.boolean().optional().describe("If true (default), returns preview results without writing sidecars."),
|
|
1311
|
-
replace_mode: z.enum(["merge", "replace"]).optional().describe("merge (default): add inferred IDs; replace: overwrite characters with inferred IDs."),
|
|
1312
|
-
max_scenes: z.number().int().positive().optional().describe("Hard guardrail for resolved scene count (default: 200)."),
|
|
1313
|
-
include_match_details: z.boolean().optional().describe("If true, include extra match diagnostics per scene."),
|
|
1314
|
-
confirm_replace: z.boolean().optional().describe("Must be true when replace_mode=replace."),
|
|
1315
|
-
},
|
|
1316
|
-
async ({
|
|
1317
|
-
project_id,
|
|
1318
|
-
scene_ids,
|
|
1319
|
-
part,
|
|
1320
|
-
chapter,
|
|
1321
|
-
only_stale = false,
|
|
1322
|
-
dry_run = true,
|
|
1323
|
-
replace_mode = "merge",
|
|
1324
|
-
max_scenes = 200,
|
|
1325
|
-
include_match_details = false,
|
|
1326
|
-
confirm_replace = false,
|
|
1327
|
-
}) => {
|
|
1328
|
-
const projectIdCheck = validateProjectId(project_id);
|
|
1329
|
-
if (!projectIdCheck.ok) {
|
|
1330
|
-
return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
if (replace_mode === "replace" && !confirm_replace) {
|
|
1334
|
-
return errorResponse(
|
|
1335
|
-
"VALIDATION_ERROR",
|
|
1336
|
-
"replace_mode=replace requires confirm_replace=true.",
|
|
1337
|
-
{ replace_mode, confirm_replace }
|
|
1338
|
-
);
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
if (!dry_run && !SYNC_DIR_WRITABLE) {
|
|
1342
|
-
return errorResponse(
|
|
1343
|
-
"READ_ONLY",
|
|
1344
|
-
"Cannot run batch character enrichment in write mode: sync dir is read-only.",
|
|
1345
|
-
{ sync_dir: SYNC_DIR_ABS }
|
|
1346
|
-
);
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
const characterRows = db.prepare(`
|
|
1350
|
-
SELECT character_id, name
|
|
1351
|
-
FROM characters
|
|
1352
|
-
WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
|
|
1353
|
-
ORDER BY length(name) DESC
|
|
1354
|
-
`).all(project_id, project_id);
|
|
1355
|
-
|
|
1356
|
-
const targetResolution = resolveBatchTargetScenes(db, {
|
|
1357
|
-
projectId: project_id,
|
|
1358
|
-
sceneIds: scene_ids,
|
|
1359
|
-
part,
|
|
1360
|
-
chapter,
|
|
1361
|
-
onlyStale: Boolean(only_stale),
|
|
1362
|
-
});
|
|
1363
|
-
if (!targetResolution.ok) {
|
|
1364
|
-
return errorResponse(targetResolution.code, targetResolution.message, targetResolution.details);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
const targetScenes = targetResolution.rows;
|
|
1368
|
-
const projectExists = targetResolution.project_exists !== false;
|
|
1369
|
-
if (targetScenes.length > max_scenes) {
|
|
1370
|
-
return errorResponse(
|
|
1371
|
-
"VALIDATION_ERROR",
|
|
1372
|
-
`Matched ${targetScenes.length} scenes, which exceeds max_scenes=${max_scenes}.`,
|
|
1373
|
-
{
|
|
1374
|
-
matched_scenes: targetScenes.length,
|
|
1375
|
-
max_scenes,
|
|
1376
|
-
project_id,
|
|
1377
|
-
next_step: maxScenesNextStep(targetScenes.length),
|
|
1378
|
-
}
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
const job = startAsyncJob({
|
|
1383
|
-
kind: "enrich_scene_characters_batch",
|
|
1384
|
-
requestPayload: {
|
|
1385
|
-
kind: "enrich_scene_characters_batch",
|
|
1386
|
-
args: {
|
|
1387
|
-
project_id,
|
|
1388
|
-
dry_run: Boolean(dry_run),
|
|
1389
|
-
replace_mode,
|
|
1390
|
-
include_match_details: Boolean(include_match_details),
|
|
1391
|
-
project_exists: projectExists,
|
|
1392
|
-
target_scenes: targetScenes,
|
|
1393
|
-
character_rows: characterRows,
|
|
1394
|
-
},
|
|
1395
|
-
context: { sync_dir: SYNC_DIR },
|
|
1396
|
-
},
|
|
1397
|
-
onComplete: (completedJob) => {
|
|
1398
|
-
if (dry_run || completedJob.status !== "completed" || !completedJob.result?.ok) return;
|
|
1399
|
-
|
|
1400
|
-
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
1401
|
-
|
|
1402
|
-
const changedScenes = (completedJob.result.results ?? [])
|
|
1403
|
-
.filter(row => row.status === "changed")
|
|
1404
|
-
.map(row => row.scene_id);
|
|
1405
|
-
|
|
1406
|
-
for (const sceneId of changedScenes) {
|
|
1407
|
-
db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
|
|
1408
|
-
.run(sceneId, project_id);
|
|
1409
|
-
}
|
|
1410
|
-
},
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
return jsonResponse({
|
|
1414
|
-
ok: true,
|
|
1415
|
-
async: true,
|
|
1416
|
-
job: toPublicJob(job, false),
|
|
1417
|
-
next_step: "Call get_async_job_status with job_id until status is 'completed', 'failed', or 'cancelled'.",
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
);
|
|
1421
|
-
|
|
1422
|
-
s.tool(
|
|
1423
|
-
"get_async_job_status",
|
|
1424
|
-
"Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta, or enrich_scene_characters_batch. Use this to poll job progress after receiving a job_id. Common next step: if status is still running, call this tool again; if status is completed inspect result, and if status is failed or cancelled inspect job/result diagnostics.",
|
|
1425
|
-
{
|
|
1426
|
-
job_id: z.string().describe("Job ID returned by an async start tool."),
|
|
1427
|
-
include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
|
|
1428
|
-
},
|
|
1429
|
-
async ({ job_id, include_result = true }) => {
|
|
1430
|
-
pruneAsyncJobs();
|
|
1431
|
-
const job = asyncJobs.get(job_id);
|
|
1432
|
-
if (!job) {
|
|
1433
|
-
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to see currently tracked job IDs.`);
|
|
1434
|
-
}
|
|
1435
|
-
return jsonResponse({ ok: true, async: true, job: toPublicJob(job, include_result) });
|
|
1436
|
-
}
|
|
1437
|
-
);
|
|
1438
|
-
|
|
1439
|
-
s.tool(
|
|
1440
|
-
"list_async_jobs",
|
|
1441
|
-
"List asynchronous jobs currently known to this server. Use this when you lost a job_id or need a dashboard view of running/completed jobs. Returns an object envelope containing a jobs array of job objects sorted by newest first.",
|
|
1442
|
-
{
|
|
1443
|
-
include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
|
|
1444
|
-
},
|
|
1445
|
-
async ({ include_results = false }) => {
|
|
1446
|
-
pruneAsyncJobs();
|
|
1447
|
-
const jobs = [...asyncJobs.values()]
|
|
1448
|
-
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
1449
|
-
.map(job => toPublicJob(job, include_results));
|
|
1450
|
-
return jsonResponse({ ok: true, async: true, jobs });
|
|
1451
|
-
}
|
|
1452
|
-
);
|
|
1453
|
-
|
|
1454
|
-
s.tool(
|
|
1455
|
-
"cancel_async_job",
|
|
1456
|
-
"Cancel a running asynchronous job. Use this when an import/merge/batch run was started with overly broad scope or is no longer needed. Returns the updated job state; cancellation is cooperative and may transition through 'cancelling' before 'cancelled'.",
|
|
1457
|
-
{
|
|
1458
|
-
job_id: z.string().describe("Job ID returned by an async start tool."),
|
|
1459
|
-
},
|
|
1460
|
-
async ({ job_id }) => {
|
|
1461
|
-
pruneAsyncJobs();
|
|
1462
|
-
const job = asyncJobs.get(job_id);
|
|
1463
|
-
if (!job) {
|
|
1464
|
-
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to find active IDs.`);
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
if (job.status !== "running") {
|
|
1468
|
-
return jsonResponse({
|
|
1469
|
-
ok: true,
|
|
1470
|
-
async: true,
|
|
1471
|
-
cancelled: false,
|
|
1472
|
-
message: `Job is already ${job.status}.`,
|
|
1473
|
-
job: toPublicJob(job, false),
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Guard: if the child has already exited, its exit handler will have
|
|
1478
|
-
// set the terminal status. Don't overwrite it.
|
|
1479
|
-
const childHasExited = job.child.exitCode !== null || job.child.signalCode !== null;
|
|
1480
|
-
if (childHasExited) {
|
|
1481
|
-
return jsonResponse({
|
|
1482
|
-
ok: true,
|
|
1483
|
-
async: true,
|
|
1484
|
-
cancelled: false,
|
|
1485
|
-
message: "Job is no longer running.",
|
|
1486
|
-
job: toPublicJob(job, false),
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
let signalSent = false;
|
|
1491
|
-
try {
|
|
1492
|
-
signalSent = job.child.kill("SIGTERM");
|
|
1493
|
-
} catch {
|
|
1494
|
-
// kill() threw — treat as signal not sent
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
if (!signalSent) {
|
|
1498
|
-
return jsonResponse({
|
|
1499
|
-
ok: true,
|
|
1500
|
-
async: true,
|
|
1501
|
-
cancelled: false,
|
|
1502
|
-
message: "Cancellation could not be requested; job may have already finished.",
|
|
1503
|
-
job: toPublicJob(job, false),
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// Transitional: signal sent but worker has not yet exited.
|
|
1508
|
-
// Exit/error handlers will finalise status to "cancelled".
|
|
1509
|
-
job.status = "cancelling";
|
|
1510
|
-
|
|
1511
|
-
return jsonResponse({
|
|
1512
|
-
ok: true,
|
|
1513
|
-
async: true,
|
|
1514
|
-
cancelled: true,
|
|
1515
|
-
message: "Cancellation requested. Poll get_async_job_status until status is 'cancelled'.",
|
|
1516
|
-
job: toPublicJob(job, false),
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
);
|
|
1016
|
+
// Passed to each tool registration module (tools/*.js) to thread state and
|
|
1017
|
+
// shared helpers without circular imports. Grows as groups are extracted.
|
|
1018
|
+
const toolContext = {
|
|
1019
|
+
db,
|
|
1020
|
+
SYNC_DIR,
|
|
1021
|
+
SYNC_DIR_ABS,
|
|
1022
|
+
SYNC_DIR_REAL,
|
|
1023
|
+
SYNC_DIR_WRITABLE,
|
|
1024
|
+
GIT_ENABLED,
|
|
1025
|
+
asyncJobs,
|
|
1026
|
+
errorResponse,
|
|
1027
|
+
jsonResponse,
|
|
1028
|
+
validateRegexPatterns,
|
|
1029
|
+
startAsyncJob,
|
|
1030
|
+
pruneAsyncJobs,
|
|
1031
|
+
toPublicJob,
|
|
1032
|
+
resolveProjectRoot,
|
|
1033
|
+
resolveBatchTargetScenes,
|
|
1034
|
+
maxScenesNextStep,
|
|
1035
|
+
isPathInsideSyncDir,
|
|
1036
|
+
deriveLoglineFromProse,
|
|
1037
|
+
inferCharacterIdsFromProse,
|
|
1038
|
+
paginateRows,
|
|
1039
|
+
DEFAULT_METADATA_PAGE_SIZE,
|
|
1040
|
+
MAX_CHAPTER_SCENES,
|
|
1041
|
+
getSceneProseAtCommit,
|
|
1042
|
+
readSupportingNotesForEntity,
|
|
1043
|
+
readEntityMetadata,
|
|
1044
|
+
};
|
|
1045
|
+
registerSyncTools(s, toolContext);
|
|
1046
|
+
registerSearchTools(s, toolContext);
|
|
1520
1047
|
|
|
1521
1048
|
// ---- get_runtime_config --------------------------------------------------
|
|
1522
1049
|
s.tool(
|
|
@@ -2376,282 +1903,6 @@ function createMcpServer() {
|
|
|
2376
1903
|
}
|
|
2377
1904
|
);
|
|
2378
1905
|
|
|
2379
|
-
// ---- find_scenes ---------------------------------------------------------
|
|
2380
|
-
s.tool(
|
|
2381
|
-
"find_scenes",
|
|
2382
|
-
"Find scenes by filtering on character, Save the Cat beat, tags, part, chapter, or POV. Returns ordered scene metadata only — no prose. All filters are optional and combinable. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata.",
|
|
2383
|
-
{
|
|
2384
|
-
project_id: z.string().optional().describe("Project ID (e.g. 'the-lamb'). Use to scope results to one project."),
|
|
2385
|
-
character: z.string().optional().describe("A character_id (e.g. 'char-mira-nystrom'). Returns only scenes that character appears in. Use list_characters first to find valid IDs."),
|
|
2386
|
-
beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Exact match."),
|
|
2387
|
-
tag: z.string().optional().describe("Scene tag to filter by. Exact match."),
|
|
2388
|
-
part: z.number().int().optional().describe("Part number (integer, e.g. 1). Chapters are numbered globally across the whole project."),
|
|
2389
|
-
chapter: z.number().int().optional().describe("Chapter number (integer, e.g. 3). Chapters are numbered globally across the whole project — do not reset per part."),
|
|
2390
|
-
pov: z.string().optional().describe("POV character_id. Use list_characters first to find valid IDs."),
|
|
2391
|
-
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
2392
|
-
page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
|
|
2393
|
-
},
|
|
2394
|
-
async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
|
|
2395
|
-
let query = `
|
|
2396
|
-
SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.chapter_title, s.pov,
|
|
2397
|
-
s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
2398
|
-
s.save_the_cat_beat, s.timeline_position, s.story_time,
|
|
2399
|
-
s.word_count, s.metadata_stale
|
|
2400
|
-
FROM scenes s
|
|
2401
|
-
`;
|
|
2402
|
-
const joins = [];
|
|
2403
|
-
const conditions = [];
|
|
2404
|
-
const params = [];
|
|
2405
|
-
|
|
2406
|
-
if (character) {
|
|
2407
|
-
joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.character_id = ?`);
|
|
2408
|
-
params.push(character);
|
|
2409
|
-
}
|
|
2410
|
-
if (tag) {
|
|
2411
|
-
joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?`);
|
|
2412
|
-
params.push(tag);
|
|
2413
|
-
}
|
|
2414
|
-
if (project_id) { conditions.push(`s.project_id = ?`); params.push(project_id); }
|
|
2415
|
-
if (beat) { conditions.push(`s.save_the_cat_beat = ?`); params.push(beat); }
|
|
2416
|
-
if (part) { conditions.push(`s.part = ?`); params.push(part); }
|
|
2417
|
-
if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
|
|
2418
|
-
if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
|
|
2419
|
-
|
|
2420
|
-
if (joins.length) query += " " + joins.join(" ");
|
|
2421
|
-
if (conditions.length) query += " WHERE " + conditions.join(" AND ");
|
|
2422
|
-
query += " ORDER BY s.part, s.chapter, s.timeline_position";
|
|
2423
|
-
|
|
2424
|
-
const rows = db.prepare(query).all(...params);
|
|
2425
|
-
if (rows.length === 0) {
|
|
2426
|
-
return errorResponse("NO_RESULTS", "No scenes match the given filters. Hint: broaden filters or call search_metadata with a keyword first.");
|
|
2427
|
-
}
|
|
2428
|
-
|
|
2429
|
-
const staleCount = rows.filter(r => r.metadata_stale).length;
|
|
2430
|
-
const warning = staleCount > 0
|
|
2431
|
-
? `${staleCount} scene(s) have stale metadata — prose has changed since last enrichment. Consider running enrich_scene() before relying on this data for analysis.`
|
|
2432
|
-
: undefined;
|
|
2433
|
-
|
|
2434
|
-
const paged = paginateRows(rows, {
|
|
2435
|
-
page,
|
|
2436
|
-
pageSize: page_size,
|
|
2437
|
-
forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
|
|
2438
|
-
});
|
|
2439
|
-
|
|
2440
|
-
const payload = paged.paginated
|
|
2441
|
-
? {
|
|
2442
|
-
results: paged.rows,
|
|
2443
|
-
...paged.meta,
|
|
2444
|
-
warning,
|
|
2445
|
-
}
|
|
2446
|
-
: rows;
|
|
2447
|
-
|
|
2448
|
-
return {
|
|
2449
|
-
content: [{
|
|
2450
|
-
type: "text",
|
|
2451
|
-
text: JSON.stringify(payload, null, 2),
|
|
2452
|
-
}],
|
|
2453
|
-
};
|
|
2454
|
-
}
|
|
2455
|
-
);
|
|
2456
|
-
|
|
2457
|
-
// ---- get_scene_prose -----------------------------------------------------
|
|
2458
|
-
s.tool(
|
|
2459
|
-
"get_scene_prose",
|
|
2460
|
-
"Load the full prose text of a single scene. Use this for close reading, continuity checks, or when you need the actual writing. For overview or filtering, use find_scenes instead — it is much cheaper. Optionally retrieve a past version from git history.",
|
|
2461
|
-
{
|
|
2462
|
-
scene_id: z.string().describe("The scene_id to retrieve (e.g. 'sc-001-prologue'). Get this from find_scenes or get_arc."),
|
|
2463
|
-
commit: z.string().optional().describe("Optional git commit hash to retrieve a past version. Use list_snapshots to find valid hashes. If omitted, returns the current prose."),
|
|
2464
|
-
},
|
|
2465
|
-
async ({ scene_id, commit }) => {
|
|
2466
|
-
const scene = db.prepare(`SELECT file_path, metadata_stale FROM scenes WHERE scene_id = ?`).get(scene_id);
|
|
2467
|
-
if (!scene) {
|
|
2468
|
-
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Run sync() if you just added it.`);
|
|
2469
|
-
}
|
|
2470
|
-
try {
|
|
2471
|
-
let rawContent;
|
|
2472
|
-
if (commit && GIT_ENABLED) {
|
|
2473
|
-
// Retrieve from git history
|
|
2474
|
-
rawContent = getSceneProseAtCommit(SYNC_DIR, scene.file_path, commit);
|
|
2475
|
-
} else if (commit && !GIT_ENABLED) {
|
|
2476
|
-
return errorResponse("GIT_UNAVAILABLE", "Git is not available — cannot retrieve historical versions.");
|
|
2477
|
-
} else {
|
|
2478
|
-
// Retrieve current version
|
|
2479
|
-
rawContent = fs.readFileSync(scene.file_path, "utf8");
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
const { content: prose } = matter(rawContent);
|
|
2483
|
-
const versionNote = commit ? `\n\n(Retrieved from commit: ${commit})` : "";
|
|
2484
|
-
const warning = scene.metadata_stale && !commit
|
|
2485
|
-
? `\n\n⚠️ Metadata for this scene may be stale — prose has changed since last enrichment.`
|
|
2486
|
-
: "";
|
|
2487
|
-
return { content: [{ type: "text", text: prose.trim() + versionNote + warning }] };
|
|
2488
|
-
} catch (err) {
|
|
2489
|
-
if (err.code === "ENOENT") {
|
|
2490
|
-
return errorResponse(
|
|
2491
|
-
"STALE_PATH",
|
|
2492
|
-
`Prose file for scene '${scene_id}' not found at indexed path — the file may have moved since the last sync. Run sync() to refresh the index.`,
|
|
2493
|
-
{ indexed_path: scene.file_path }
|
|
2494
|
-
);
|
|
2495
|
-
}
|
|
2496
|
-
return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
);
|
|
2500
|
-
|
|
2501
|
-
// ---- get_chapter_prose ---------------------------------------------------
|
|
2502
|
-
s.tool(
|
|
2503
|
-
"get_chapter_prose",
|
|
2504
|
-
`Load the full prose for every scene in a chapter, concatenated in order. Expensive — only use when you need to read an entire chapter. Capped at ${MAX_CHAPTER_SCENES} scenes. Use find_scenes first to confirm the chapter exists.`,
|
|
2505
|
-
{
|
|
2506
|
-
project_id: z.string().describe("Project ID (e.g. 'the-lamb')."),
|
|
2507
|
-
part: z.number().int().describe("Part number (integer)."),
|
|
2508
|
-
chapter: z.number().int().describe("Chapter number (integer, globally numbered across the whole project)."),
|
|
2509
|
-
},
|
|
2510
|
-
async ({ project_id, part, chapter }) => {
|
|
2511
|
-
const allScenes = db.prepare(`
|
|
2512
|
-
SELECT scene_id, title, file_path FROM scenes
|
|
2513
|
-
WHERE project_id = ? AND part = ? AND chapter = ?
|
|
2514
|
-
ORDER BY timeline_position
|
|
2515
|
-
`).all(project_id, part, chapter);
|
|
2516
|
-
|
|
2517
|
-
if (allScenes.length === 0) {
|
|
2518
|
-
return errorResponse("NO_RESULTS", `No scenes found for Part ${part}, Chapter ${chapter}.`);
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
const truncated = allScenes.length > MAX_CHAPTER_SCENES;
|
|
2522
|
-
const scenes = truncated ? allScenes.slice(0, MAX_CHAPTER_SCENES) : allScenes;
|
|
2523
|
-
|
|
2524
|
-
const parts = [];
|
|
2525
|
-
for (const scene of scenes) {
|
|
2526
|
-
try {
|
|
2527
|
-
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
2528
|
-
const { content: prose } = matter(raw);
|
|
2529
|
-
parts.push(`## ${scene.title ?? scene.scene_id}\n\n${prose.trim()}`);
|
|
2530
|
-
} catch (err) {
|
|
2531
|
-
parts.push(`## ${scene.scene_id}\n\n[Error reading file: ${err.message}]`);
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
const warning = truncated
|
|
2536
|
-
? `\n\n⚠️ Chapter has ${allScenes.length} scenes — only the first ${MAX_CHAPTER_SCENES} were loaded. Set MAX_CHAPTER_SCENES to increase this limit.`
|
|
2537
|
-
: "";
|
|
2538
|
-
return { content: [{ type: "text", text: parts.join("\n\n---\n\n") + warning }] };
|
|
2539
|
-
}
|
|
2540
|
-
);
|
|
2541
|
-
|
|
2542
|
-
// ---- get_arc -------------------------------------------------------------
|
|
2543
|
-
s.tool(
|
|
2544
|
-
"get_arc",
|
|
2545
|
-
"Get every scene a character appears in, ordered by part/chapter/position. Returns scene metadata only — no prose. Use this to trace a character's arc through the story. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Call list_characters first to get the character_id.",
|
|
2546
|
-
{
|
|
2547
|
-
character_id: z.string().describe("The character_id to trace (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
|
|
2548
|
-
project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
|
|
2549
|
-
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
2550
|
-
page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
|
|
2551
|
-
},
|
|
2552
|
-
async ({ character_id, project_id, page, page_size }) => {
|
|
2553
|
-
let query = `
|
|
2554
|
-
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
|
|
2555
|
-
s.scene_change, s.causality, s.stakes, s.scene_functions,
|
|
2556
|
-
s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
|
|
2557
|
-
FROM scenes s
|
|
2558
|
-
JOIN scene_characters sc ON sc.scene_id = s.scene_id
|
|
2559
|
-
WHERE sc.character_id = ?
|
|
2560
|
-
`;
|
|
2561
|
-
const params = [character_id];
|
|
2562
|
-
if (project_id) { query += ` AND s.project_id = ?`; params.push(project_id); }
|
|
2563
|
-
query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
|
|
2564
|
-
|
|
2565
|
-
const rows = db.prepare(query).all(...params);
|
|
2566
|
-
if (rows.length === 0) {
|
|
2567
|
-
return errorResponse("NO_RESULTS", `No scenes found for character '${character_id}'.`);
|
|
2568
|
-
}
|
|
2569
|
-
|
|
2570
|
-
const staleCount = rows.filter(r => r.metadata_stale).length;
|
|
2571
|
-
const warning = staleCount > 0
|
|
2572
|
-
? `${staleCount} scene(s) have stale metadata.`
|
|
2573
|
-
: undefined;
|
|
2574
|
-
|
|
2575
|
-
const paged = paginateRows(rows, {
|
|
2576
|
-
page,
|
|
2577
|
-
pageSize: page_size,
|
|
2578
|
-
forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
|
|
2579
|
-
});
|
|
2580
|
-
|
|
2581
|
-
const payload = paged.paginated
|
|
2582
|
-
? {
|
|
2583
|
-
results: paged.rows,
|
|
2584
|
-
...paged.meta,
|
|
2585
|
-
warning,
|
|
2586
|
-
}
|
|
2587
|
-
: rows;
|
|
2588
|
-
|
|
2589
|
-
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
2590
|
-
}
|
|
2591
|
-
);
|
|
2592
|
-
|
|
2593
|
-
// ---- list_characters -----------------------------------------------------
|
|
2594
|
-
s.tool(
|
|
2595
|
-
"list_characters",
|
|
2596
|
-
"List all indexed characters with their character_id, name, role, and arc_summary. Call this first whenever you need to filter scenes by character or look up a character sheet — it gives you the character_id values required by other tools.",
|
|
2597
|
-
{
|
|
2598
|
-
project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
|
|
2599
|
-
universe_id: z.string().optional().describe("Limit to a specific universe (if using cross-project world-building)."),
|
|
2600
|
-
},
|
|
2601
|
-
async ({ project_id, universe_id }) => {
|
|
2602
|
-
let query = `SELECT character_id, name, role, arc_summary, project_id, universe_id FROM characters`;
|
|
2603
|
-
const conditions = [];
|
|
2604
|
-
const params = [];
|
|
2605
|
-
if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
|
|
2606
|
-
if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
|
|
2607
|
-
if (conditions.length) query += " WHERE " + conditions.join(" AND ");
|
|
2608
|
-
query += " ORDER BY name";
|
|
2609
|
-
|
|
2610
|
-
const rows = db.prepare(query).all(...params);
|
|
2611
|
-
if (rows.length === 0) {
|
|
2612
|
-
return errorResponse("NO_RESULTS", "No characters found.");
|
|
2613
|
-
}
|
|
2614
|
-
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
2615
|
-
}
|
|
2616
|
-
);
|
|
2617
|
-
|
|
2618
|
-
// ---- get_character_sheet -------------------------------------------------
|
|
2619
|
-
s.tool(
|
|
2620
|
-
"get_character_sheet",
|
|
2621
|
-
"Get full character details: role, arc_summary, traits, the canonical sheet content, and any adjacent support notes when the character uses a folder-based layout. Use list_characters first to get the character_id.",
|
|
2622
|
-
{
|
|
2623
|
-
character_id: z.string().describe("The character_id to look up (e.g. 'char-sebastian'). Use list_characters to find valid IDs."),
|
|
2624
|
-
},
|
|
2625
|
-
async ({ character_id }) => {
|
|
2626
|
-
const character = db.prepare(`SELECT * FROM characters WHERE character_id = ?`).get(character_id);
|
|
2627
|
-
if (!character) {
|
|
2628
|
-
return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
|
|
2629
|
-
}
|
|
2630
|
-
|
|
2631
|
-
const traits = db.prepare(`SELECT trait FROM character_traits WHERE character_id = ?`)
|
|
2632
|
-
.all(character_id).map(r => r.trait);
|
|
2633
|
-
|
|
2634
|
-
let notes = "";
|
|
2635
|
-
let supportingNotes = [];
|
|
2636
|
-
if (character.file_path) {
|
|
2637
|
-
try {
|
|
2638
|
-
const raw = fs.readFileSync(character.file_path, "utf8");
|
|
2639
|
-
const { content } = matter(raw);
|
|
2640
|
-
notes = content.trim();
|
|
2641
|
-
supportingNotes = readSupportingNotesForEntity(character.file_path);
|
|
2642
|
-
} catch { /* empty */ }
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
const result = {
|
|
2646
|
-
...character,
|
|
2647
|
-
traits,
|
|
2648
|
-
notes: notes || undefined,
|
|
2649
|
-
supporting_notes: supportingNotes.length ? supportingNotes : undefined,
|
|
2650
|
-
};
|
|
2651
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2652
|
-
}
|
|
2653
|
-
);
|
|
2654
|
-
|
|
2655
1906
|
// ---- create_character_sheet ---------------------------------------------
|
|
2656
1907
|
s.tool(
|
|
2657
1908
|
"create_character_sheet",
|
|
@@ -2703,31 +1954,6 @@ function createMcpServer() {
|
|
|
2703
1954
|
}
|
|
2704
1955
|
);
|
|
2705
1956
|
|
|
2706
|
-
// ---- list_places ---------------------------------------------------------
|
|
2707
|
-
s.tool(
|
|
2708
|
-
"list_places",
|
|
2709
|
-
"List all indexed places with their place_id and name. Use this to find place_id values for scene filtering or to get an overview of the story's locations.",
|
|
2710
|
-
{
|
|
2711
|
-
project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
|
|
2712
|
-
universe_id: z.string().optional().describe("Limit to a specific universe."),
|
|
2713
|
-
},
|
|
2714
|
-
async ({ project_id, universe_id }) => {
|
|
2715
|
-
let query = `SELECT place_id, name, project_id, universe_id FROM places`;
|
|
2716
|
-
const conditions = [];
|
|
2717
|
-
const params = [];
|
|
2718
|
-
if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
|
|
2719
|
-
if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
|
|
2720
|
-
if (conditions.length) query += " WHERE " + conditions.join(" AND ");
|
|
2721
|
-
query += " ORDER BY name";
|
|
2722
|
-
|
|
2723
|
-
const rows = db.prepare(query).all(...params);
|
|
2724
|
-
if (rows.length === 0) {
|
|
2725
|
-
return errorResponse("NO_RESULTS", "No places found.");
|
|
2726
|
-
}
|
|
2727
|
-
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
2728
|
-
}
|
|
2729
|
-
);
|
|
2730
|
-
|
|
2731
1957
|
// ---- create_place_sheet -------------------------------------------------
|
|
2732
1958
|
s.tool(
|
|
2733
1959
|
"create_place_sheet",
|
|
@@ -2777,189 +2003,6 @@ function createMcpServer() {
|
|
|
2777
2003
|
}
|
|
2778
2004
|
);
|
|
2779
2005
|
|
|
2780
|
-
// ---- get_place_sheet -----------------------------------------------------
|
|
2781
|
-
s.tool(
|
|
2782
|
-
"get_place_sheet",
|
|
2783
|
-
"Get full place details: associated_characters, tags, the canonical sheet content, and any adjacent support notes when the place uses a folder-based layout. Use list_places first to get the place_id.",
|
|
2784
|
-
{
|
|
2785
|
-
place_id: z.string().describe("The place_id to look up (e.g. 'place-harbor-district'). Use list_places to find valid IDs."),
|
|
2786
|
-
},
|
|
2787
|
-
async ({ place_id }) => {
|
|
2788
|
-
const place = db.prepare(`SELECT * FROM places WHERE place_id = ?`).get(place_id);
|
|
2789
|
-
if (!place) {
|
|
2790
|
-
return errorResponse("NOT_FOUND", `Place '${place_id}' not found.`);
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
let notes = "";
|
|
2794
|
-
let supportingNotes = [];
|
|
2795
|
-
let associatedCharacters = [];
|
|
2796
|
-
let tags = [];
|
|
2797
|
-
|
|
2798
|
-
if (place.file_path) {
|
|
2799
|
-
try {
|
|
2800
|
-
const raw = fs.readFileSync(place.file_path, "utf8");
|
|
2801
|
-
const { content } = matter(raw);
|
|
2802
|
-
notes = content.trim();
|
|
2803
|
-
supportingNotes = readSupportingNotesForEntity(place.file_path);
|
|
2804
|
-
|
|
2805
|
-
const meta = readEntityMetadata(place.file_path);
|
|
2806
|
-
associatedCharacters = Array.isArray(meta.associated_characters) ? meta.associated_characters : [];
|
|
2807
|
-
tags = Array.isArray(meta.tags) ? meta.tags : [];
|
|
2808
|
-
} catch { /* empty */ }
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
const result = {
|
|
2812
|
-
...place,
|
|
2813
|
-
associated_characters: associatedCharacters.length ? associatedCharacters : undefined,
|
|
2814
|
-
tags: tags.length ? tags : undefined,
|
|
2815
|
-
notes: notes || undefined,
|
|
2816
|
-
supporting_notes: supportingNotes.length ? supportingNotes : undefined,
|
|
2817
|
-
};
|
|
2818
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
2819
|
-
}
|
|
2820
|
-
);
|
|
2821
|
-
|
|
2822
|
-
// ---- search_metadata -----------------------------------------------------
|
|
2823
|
-
s.tool(
|
|
2824
|
-
"search_metadata",
|
|
2825
|
-
"Full-text search across scene titles, loglines (synopsis/logline text fields), and metadata keywords (tags/characters/places/versions). Use this when you don't know the exact scene_id or chapter but want to find scenes by topic, theme, or metadata keyword. Not a prose search — use get_scene_prose to read actual text. Supports pagination via page/page_size and auto-paginates large result sets with total_count.",
|
|
2826
|
-
{
|
|
2827
|
-
query: z.string().describe("Search terms (e.g. 'hospital' or 'Sebastian feeding'). FTS5 syntax supported."),
|
|
2828
|
-
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
2829
|
-
page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
|
|
2830
|
-
},
|
|
2831
|
-
async ({ query, page, page_size }) => {
|
|
2832
|
-
let totalCount;
|
|
2833
|
-
try {
|
|
2834
|
-
totalCount = db.prepare(`
|
|
2835
|
-
SELECT COUNT(*) AS count
|
|
2836
|
-
FROM scenes_fts f
|
|
2837
|
-
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
2838
|
-
WHERE scenes_fts MATCH ?
|
|
2839
|
-
`).get(query)?.count ?? 0;
|
|
2840
|
-
} catch (err) {
|
|
2841
|
-
return errorResponse("INVALID_QUERY", "Invalid search query syntax. Use plain keywords or quoted phrases.", { detail: err.message });
|
|
2842
|
-
}
|
|
2843
|
-
|
|
2844
|
-
if (totalCount === 0) {
|
|
2845
|
-
return errorResponse("NO_RESULTS", "No scenes matched the search query.");
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
const shouldPaginate = totalCount > DEFAULT_METADATA_PAGE_SIZE || page !== undefined || page_size !== undefined;
|
|
2849
|
-
|
|
2850
|
-
if (!shouldPaginate) {
|
|
2851
|
-
const rows = db.prepare(`
|
|
2852
|
-
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
|
|
2853
|
-
FROM scenes_fts f
|
|
2854
|
-
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
2855
|
-
WHERE scenes_fts MATCH ?
|
|
2856
|
-
ORDER BY rank
|
|
2857
|
-
`).all(query);
|
|
2858
|
-
|
|
2859
|
-
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
const safePageSize = Math.max(1, page_size ?? DEFAULT_METADATA_PAGE_SIZE);
|
|
2863
|
-
const safePage = Math.max(1, page ?? 1);
|
|
2864
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / safePageSize));
|
|
2865
|
-
const normalizedPage = Math.min(safePage, totalPages);
|
|
2866
|
-
const offset = (normalizedPage - 1) * safePageSize;
|
|
2867
|
-
|
|
2868
|
-
const rows = db.prepare(`
|
|
2869
|
-
SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.chapter_title, s.metadata_stale
|
|
2870
|
-
FROM scenes_fts f
|
|
2871
|
-
JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
|
|
2872
|
-
WHERE scenes_fts MATCH ?
|
|
2873
|
-
ORDER BY rank
|
|
2874
|
-
LIMIT ? OFFSET ?
|
|
2875
|
-
`).all(query, safePageSize, offset);
|
|
2876
|
-
|
|
2877
|
-
const payload = {
|
|
2878
|
-
results: rows,
|
|
2879
|
-
total_count: totalCount,
|
|
2880
|
-
page: normalizedPage,
|
|
2881
|
-
page_size: safePageSize,
|
|
2882
|
-
total_pages: totalPages,
|
|
2883
|
-
has_next_page: normalizedPage < totalPages,
|
|
2884
|
-
has_prev_page: normalizedPage > 1,
|
|
2885
|
-
};
|
|
2886
|
-
|
|
2887
|
-
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
2888
|
-
}
|
|
2889
|
-
);
|
|
2890
|
-
|
|
2891
|
-
// ---- list_threads --------------------------------------------------------
|
|
2892
|
-
s.tool(
|
|
2893
|
-
"list_threads",
|
|
2894
|
-
"List all subplot/storyline threads for a project. Returns a structured JSON envelope with results and total_count. Use this to discover valid thread_id values before calling get_thread_arc or upsert_thread_link. Supports pagination via page/page_size.",
|
|
2895
|
-
{
|
|
2896
|
-
project_id: z.string().describe("Project ID."),
|
|
2897
|
-
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
2898
|
-
page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
|
|
2899
|
-
},
|
|
2900
|
-
async ({ project_id, page, page_size }) => {
|
|
2901
|
-
const rows = db.prepare(`SELECT * FROM threads WHERE project_id = ? ORDER BY name`).all(project_id);
|
|
2902
|
-
const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
|
|
2903
|
-
const payload = paged.paginated
|
|
2904
|
-
? {
|
|
2905
|
-
project_id,
|
|
2906
|
-
results: paged.rows,
|
|
2907
|
-
...paged.meta,
|
|
2908
|
-
}
|
|
2909
|
-
: {
|
|
2910
|
-
project_id,
|
|
2911
|
-
results: rows,
|
|
2912
|
-
total_count: rows.length,
|
|
2913
|
-
};
|
|
2914
|
-
|
|
2915
|
-
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
2916
|
-
}
|
|
2917
|
-
);
|
|
2918
|
-
|
|
2919
|
-
// ---- get_thread_arc ------------------------------------------------------
|
|
2920
|
-
s.tool(
|
|
2921
|
-
"get_thread_arc",
|
|
2922
|
-
"Get ordered scene metadata for all scenes belonging to a thread, including the per-thread beat. Returns a structured JSON envelope with thread metadata, results, and total_count. Use list_threads first to find a valid thread_id, then call get_scene_prose for close reading of specific scenes. Supports pagination via page/page_size.",
|
|
2923
|
-
{
|
|
2924
|
-
thread_id: z.string().describe("Thread ID."),
|
|
2925
|
-
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
2926
|
-
page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
|
|
2927
|
-
},
|
|
2928
|
-
async ({ thread_id, page, page_size }) => {
|
|
2929
|
-
const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
2930
|
-
if (!thread) {
|
|
2931
|
-
return errorResponse("NOT_FOUND", `Thread '${thread_id}' not found. Hint: call list_threads with project_id to get valid thread IDs.`);
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
const rows = db.prepare(`
|
|
2935
|
-
SELECT s.scene_id, s.project_id, s.part, s.chapter, s.chapter_title, s.title, s.logline,
|
|
2936
|
-
st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
|
|
2937
|
-
FROM scenes s
|
|
2938
|
-
JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
|
|
2939
|
-
ORDER BY s.part, s.chapter, s.timeline_position
|
|
2940
|
-
`).all(thread_id);
|
|
2941
|
-
const staleCount = rows.filter(r => r.metadata_stale).length;
|
|
2942
|
-
const warning = staleCount > 0 ? `${staleCount} scene(s) have stale metadata.` : undefined;
|
|
2943
|
-
const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
|
|
2944
|
-
|
|
2945
|
-
const payload = paged.paginated
|
|
2946
|
-
? {
|
|
2947
|
-
thread,
|
|
2948
|
-
results: paged.rows,
|
|
2949
|
-
...paged.meta,
|
|
2950
|
-
warning,
|
|
2951
|
-
}
|
|
2952
|
-
: {
|
|
2953
|
-
thread,
|
|
2954
|
-
results: rows,
|
|
2955
|
-
total_count: rows.length,
|
|
2956
|
-
warning,
|
|
2957
|
-
};
|
|
2958
|
-
|
|
2959
|
-
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
2960
|
-
}
|
|
2961
|
-
);
|
|
2962
|
-
|
|
2963
2006
|
// ---- upsert_thread_link ---------------------------------------------------
|
|
2964
2007
|
s.tool(
|
|
2965
2008
|
"upsert_thread_link",
|
|
@@ -3018,73 +2061,6 @@ function createMcpServer() {
|
|
|
3018
2061
|
}
|
|
3019
2062
|
);
|
|
3020
2063
|
|
|
3021
|
-
// ---- enrich_scene --------------------------------------------------------
|
|
3022
|
-
s.tool(
|
|
3023
|
-
"enrich_scene",
|
|
3024
|
-
"Re-derive lightweight scene metadata from current prose (logline and character mentions) and clear metadata_stale for that scene. Only available when the sync dir is writable.",
|
|
3025
|
-
{
|
|
3026
|
-
scene_id: z.string().describe("Scene to enrich (e.g. 'sc-011-sebastian')."),
|
|
3027
|
-
project_id: z.string().optional().describe("Project ID. Required when scene_id is duplicated across projects."),
|
|
3028
|
-
},
|
|
3029
|
-
async ({ scene_id, project_id }) => {
|
|
3030
|
-
if (!SYNC_DIR_WRITABLE) {
|
|
3031
|
-
return errorResponse("READ_ONLY", "Cannot enrich scene: sync dir is read-only.");
|
|
3032
|
-
}
|
|
3033
|
-
|
|
3034
|
-
let scene;
|
|
3035
|
-
if (project_id) {
|
|
3036
|
-
scene = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
|
|
3037
|
-
.get(scene_id, project_id);
|
|
3038
|
-
} else {
|
|
3039
|
-
const matches = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ?`).all(scene_id);
|
|
3040
|
-
if (matches.length > 1) {
|
|
3041
|
-
return errorResponse("VALIDATION_ERROR", `Scene '${scene_id}' exists in multiple projects. Provide project_id.`);
|
|
3042
|
-
}
|
|
3043
|
-
scene = matches[0];
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
if (!scene) {
|
|
3047
|
-
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found${project_id ? ` in project '${project_id}'` : ""}.`);
|
|
3048
|
-
}
|
|
3049
|
-
|
|
3050
|
-
try {
|
|
3051
|
-
const raw = fs.readFileSync(scene.file_path, "utf8");
|
|
3052
|
-
const { content: prose } = matter(raw);
|
|
3053
|
-
const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
|
|
3054
|
-
|
|
3055
|
-
const inferredLogline = deriveLoglineFromProse(prose);
|
|
3056
|
-
const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
|
|
3057
|
-
|
|
3058
|
-
const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, {
|
|
3059
|
-
...meta,
|
|
3060
|
-
...(inferredLogline ? { logline: inferredLogline } : {}),
|
|
3061
|
-
...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
|
|
3062
|
-
? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
|
|
3063
|
-
: {}),
|
|
3064
|
-
}).meta;
|
|
3065
|
-
|
|
3066
|
-
writeMeta(scene.file_path, updatedMeta);
|
|
3067
|
-
indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose);
|
|
3068
|
-
db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
|
|
3069
|
-
.run(scene.scene_id, scene.project_id);
|
|
3070
|
-
|
|
3071
|
-
return jsonResponse({
|
|
3072
|
-
ok: true,
|
|
3073
|
-
action: "enriched",
|
|
3074
|
-
scene_id: scene.scene_id,
|
|
3075
|
-
project_id: scene.project_id,
|
|
3076
|
-
updated_fields: {
|
|
3077
|
-
logline: Boolean(inferredLogline),
|
|
3078
|
-
characters: inferredCharacters.length,
|
|
3079
|
-
},
|
|
3080
|
-
metadata_stale: false,
|
|
3081
|
-
});
|
|
3082
|
-
} catch (err) {
|
|
3083
|
-
return errorResponse("IO_ERROR", `Failed to enrich scene '${scene.scene_id}': ${err.message}`);
|
|
3084
|
-
}
|
|
3085
|
-
}
|
|
3086
|
-
);
|
|
3087
|
-
|
|
3088
2064
|
// ---- update_scene_metadata -----------------------------------------------
|
|
3089
2065
|
s.tool(
|
|
3090
2066
|
"update_scene_metadata",
|
|
@@ -3260,36 +2236,6 @@ function createMcpServer() {
|
|
|
3260
2236
|
}
|
|
3261
2237
|
);
|
|
3262
2238
|
|
|
3263
|
-
// ---- get_relationship_arc ------------------------------------------------
|
|
3264
|
-
s.tool(
|
|
3265
|
-
"get_relationship_arc",
|
|
3266
|
-
"Show how the relationship between two characters evolves across scenes, in order. Uses explicitly recorded relationship entries — returns nothing if no entries exist yet. Use list_characters to get character_id values.",
|
|
3267
|
-
{
|
|
3268
|
-
from_character: z.string().describe("character_id of the first character (e.g. 'char-sebastian')."),
|
|
3269
|
-
to_character: z.string().describe("character_id of the second character (e.g. 'char-mira-nystrom')."),
|
|
3270
|
-
project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
|
|
3271
|
-
},
|
|
3272
|
-
async ({ from_character, to_character, project_id }) => {
|
|
3273
|
-
let query = `
|
|
3274
|
-
SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
|
|
3275
|
-
r.scene_id, r.note,
|
|
3276
|
-
s.part, s.chapter, s.chapter_title, s.timeline_position, s.title AS scene_title
|
|
3277
|
-
FROM character_relationships r
|
|
3278
|
-
LEFT JOIN scenes s ON s.scene_id = r.scene_id
|
|
3279
|
-
WHERE r.from_character = ? AND r.to_character = ?
|
|
3280
|
-
`;
|
|
3281
|
-
const params = [from_character, to_character];
|
|
3282
|
-
if (project_id) { query += ` AND (s.project_id = ? OR r.scene_id IS NULL)`; params.push(project_id); }
|
|
3283
|
-
query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
|
|
3284
|
-
|
|
3285
|
-
const rows = db.prepare(query).all(...params);
|
|
3286
|
-
if (rows.length === 0) {
|
|
3287
|
-
return errorResponse("NO_RESULTS", `No relationship data found between '${from_character}' and '${to_character}'.`);
|
|
3288
|
-
}
|
|
3289
|
-
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
3290
|
-
}
|
|
3291
|
-
);
|
|
3292
|
-
|
|
3293
2239
|
// ---- PHASE 3: Prose Editing (git-backed) --------------------------------
|
|
3294
2240
|
|
|
3295
2241
|
// ---- propose_edit --------------------------------------------------------
|