@hanna84/mcp-writing 1.7.0 → 1.8.1

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,31 @@ 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
+ #### [v1.8.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.8.0...v1.8.1)
9
+
10
+ - Fix inferProjectAndUniverse misidentifying two-segment universe paths under projects/ [`#51`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/51)
12
+
13
+ #### [v1.8.0](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.7.0...v1.8.0)
15
+
16
+ > 19 April 2026
17
+
18
+ - feat(search): index metadata keywords in scenes FTS and preserve legacy index data [`#50`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/50)
20
+ - Release 1.8.0 [`5e3c4fd`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/5e3c4fd6fa4caade6540d3228bfa52ee5f42c923)
22
+
7
23
  #### [v1.7.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.6.3...v1.7.0)
9
25
 
26
+ > 19 April 2026
27
+
10
28
  - feat: configurable OpenClaw runtime identity and ownership guardrails [`#49`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/49)
30
+ - Release 1.7.0 [`f859eb5`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/f859eb57bcea412591635185e113f2c6467b0f59)
12
32
 
13
33
  #### [v1.6.3](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.6.2...v1.6.3)
package/README.md CHANGED
@@ -315,7 +315,7 @@ Outcome: you get AI speed with explicit approval and recoverable history for eve
315
315
  | `list_places` | All places |
316
316
  | `get_place_sheet` | Full place metadata, tags, associated characters, notes, and support notes |
317
317
  | `create_place_sheet` | Create a canonical place sheet folder and sidecar |
318
- | `search_metadata` | Full-text search across scene titles and loglines |
318
+ | `search_metadata` | Full-text search across scene titles, loglines, and metadata keywords (tags/characters/places/versions) |
319
319
  | `list_threads` | All subplot threads for a project |
320
320
  | `get_thread_arc` | Scenes belonging to a thread, with per-thread beat |
321
321
  | `upsert_thread_link` | Create/update a thread and link it to a scene |
package/db.js CHANGED
@@ -116,12 +116,43 @@ export const SCHEMA = `
116
116
  );
117
117
 
118
118
  CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
119
- scene_id, project_id, logline, title
119
+ scene_id, project_id, logline, title, keywords
120
120
  );
121
121
  `;
122
122
 
123
123
  export function openDb(dbPath) {
124
124
  const db = new DatabaseSync(dbPath);
125
125
  db.exec(SCHEMA);
126
+
127
+ // Rebuild legacy FTS table if it predates keyword indexing.
128
+ // Preserve existing indexed rows so metadata search remains available
129
+ // even before the next sync pass repopulates from source files.
130
+ const ftsSql = db.prepare(`
131
+ SELECT sql
132
+ FROM sqlite_master
133
+ WHERE type = 'table' AND name = 'scenes_fts'
134
+ `).get()?.sql;
135
+ if (typeof ftsSql === "string" && !ftsSql.toLowerCase().includes("keywords")) {
136
+ db.exec(`BEGIN IMMEDIATE;`);
137
+ try {
138
+ db.exec(`
139
+ CREATE VIRTUAL TABLE scenes_fts_migrating USING fts5(
140
+ scene_id, project_id, logline, title, keywords
141
+ );
142
+ `);
143
+ db.exec(`
144
+ INSERT INTO scenes_fts_migrating (scene_id, project_id, logline, title, keywords)
145
+ SELECT scene_id, project_id, logline, title, ''
146
+ FROM scenes_fts;
147
+ `);
148
+ db.exec(`DROP TABLE scenes_fts;`);
149
+ db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
150
+ db.exec(`COMMIT;`);
151
+ } catch (err) {
152
+ db.exec(`ROLLBACK;`);
153
+ throw err;
154
+ }
155
+ }
156
+
126
157
  return db;
127
158
  }
package/index.js CHANGED
@@ -946,7 +946,7 @@ function createMcpServer() {
946
946
  // ---- search_metadata -----------------------------------------------------
947
947
  s.tool(
948
948
  "search_metadata",
949
- "Full-text search across scene titles and loglines (synopsis/logline text fields). Use this when you don't know the exact scene_id or chapter but want to find scenes by topic, theme, or keywords in the description. 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.",
949
+ "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.",
950
950
  {
951
951
  query: z.string().describe("Search terms (e.g. 'hospital' or 'Sebastian feeding'). FTS5 syntax supported."),
952
952
  page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/sync.js CHANGED
@@ -96,17 +96,66 @@ export function normalizeSceneMetaForPath(syncDir, filePath, meta = {}) {
96
96
  };
97
97
  }
98
98
 
99
+ // Structural directory names that are never project slugs under projects/<id>/.
100
+ const PROJECT_STRUCTURAL_DIRS = new Set(["world", "scenes", "misc", "fragments", "feedback", "draft"]);
101
+
102
+ // Cache universe project root existence checks during sync scans.
103
+ const UNIVERSE_PROJECT_ROOT_CACHE = new Map();
104
+
105
+ // Returns true for known structural path segments directly under a project root
106
+ // (named dirs like "world", "scenes", and part-N / chapter-N path segments).
107
+ function isProjectStructuralDir(name) {
108
+ const normalized = String(name ?? "").toLowerCase();
109
+ return PROJECT_STRUCTURAL_DIRS.has(normalized)
110
+ || /^part-\d+$/.test(normalized)
111
+ || /^chapter-\d+$/.test(normalized);
112
+ }
113
+
114
+ function isBookSlug(name) {
115
+ return /^book-[a-z0-9][a-z0-9-]*$/i.test(String(name ?? ""));
116
+ }
117
+
118
+ function hasUniverseProjectRoot(syncDir, universeId, projectSlug) {
119
+ const key = `${syncDir}::${universeId}/${projectSlug}`;
120
+ if (UNIVERSE_PROJECT_ROOT_CACHE.has(key)) {
121
+ return UNIVERSE_PROJECT_ROOT_CACHE.get(key);
122
+ }
123
+
124
+ const exists = fs.existsSync(path.join(syncDir, "universes", universeId, projectSlug));
125
+ UNIVERSE_PROJECT_ROOT_CACHE.set(key, exists);
126
+ return exists;
127
+ }
128
+
99
129
  export function inferProjectAndUniverse(syncDir, filePath) {
100
130
  const rel = path.relative(syncDir, filePath);
101
131
  const parts = rel.split(path.sep);
102
132
 
103
133
  if (parts[0] === "universes" && parts.length >= 3) {
134
+ // Case-sensitive "world" intentionally matches isWorldFile() which also uses lowercase.
104
135
  if (parts[2] === "world") {
105
136
  return { universe_id: parts[1], project_id: null };
106
137
  }
107
138
  return { universe_id: parts[1], project_id: `${parts[1]}/${parts[2]}` };
108
139
  }
109
140
  if (parts[0] === "projects" && parts.length >= 2) {
141
+ // Detect accidental two-segment layout: projects/<universe>/<book>/...
142
+ // This occurs when a universe-scoped project_id (e.g. "universe-1/book-1-the-lamb")
143
+ // is written under projects/ instead of universes/.
144
+ // Detection is deliberately conservative to avoid mis-classifying valid nested
145
+ // project layouts (e.g. projects/my-novel/notes/...). All three conditions must hold:
146
+ // 1. parts[2] matches a book-* slug pattern (book-1, book-one, book-1-the-lamb, …)
147
+ // 2. parts[3] is a known structural directory (scenes, world, part-N, chapter-N, …)
148
+ // 3. A matching universes/<universe>/<book> directory exists on disk
149
+ if (
150
+ parts.length >= 4
151
+ && parts[2] !== undefined
152
+ && parts[3] !== undefined
153
+ && isBookSlug(parts[2])
154
+ && isProjectStructuralDir(parts[3])
155
+ && hasUniverseProjectRoot(syncDir, parts[1], parts[2])
156
+ ) {
157
+ return { universe_id: parts[1], project_id: `${parts[1]}/${parts[2]}` };
158
+ }
110
159
  return { universe_id: null, project_id: parts[1] };
111
160
  }
112
161
  return { universe_id: null, project_id: parts[0] ?? "default" };
@@ -515,14 +564,34 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
515
564
  );
516
565
  }
517
566
 
518
- db.prepare(`INSERT OR REPLACE INTO scenes_fts (scene_id, project_id, logline, title) VALUES (?, ?, ?, ?)`).run(
519
- meta.scene_id, project_id, meta.logline ?? meta.synopsis ?? "", meta.title ?? ""
567
+ const keywordTokens = [
568
+ ...(meta.tags ?? []),
569
+ ...(meta.characters ?? []),
570
+ ...(meta.places ?? []),
571
+ ...(meta.versions ?? []),
572
+ ]
573
+ .filter(Boolean)
574
+ .map(String)
575
+ .map(s => s.trim())
576
+ .filter(Boolean)
577
+ .join(" ");
578
+
579
+ db.prepare(`INSERT OR REPLACE INTO scenes_fts (scene_id, project_id, logline, title, keywords) VALUES (?, ?, ?, ?, ?)`).run(
580
+ meta.scene_id,
581
+ project_id,
582
+ meta.logline ?? meta.synopsis ?? "",
583
+ meta.title ?? "",
584
+ keywordTokens,
520
585
  );
521
586
 
522
587
  return { isStale };
523
588
  }
524
589
 
525
590
  export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
591
+ // Reset per-run inference cache so filesystem changes between sync calls
592
+ // (for example after imports or path repairs) are reflected immediately.
593
+ UNIVERSE_PROJECT_ROOT_CACHE.clear();
594
+
526
595
  const files = walkFiles(syncDir);
527
596
  let indexed = 0;
528
597
  let staleMarked = 0;