@hanna84/mcp-writing 2.12.6 → 2.12.8

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
+ #### [v2.12.8](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.12.7...v2.12.8)
9
+
10
+ - refactor(domains): move review, styleguide, and workflow modules into src [`#123`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/123)
12
+
13
+ #### [v2.12.7](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v2.12.6...v2.12.7)
15
+
16
+ > 28 April 2026
17
+
18
+ - refactor(core): move db and git modules under src/core [`#122`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/122)
20
+ - Release 2.12.7 [`4644e20`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/4644e20a0b63f508e7a9578c600596dd546b4653)
22
+
7
23
  #### [v2.12.6](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.12.5...v2.12.6)
9
25
 
26
+ > 28 April 2026
27
+
10
28
  - refactor: move sync and world modules under src domains [`#120`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/120)
30
+ - Release 2.12.6 [`e20c399`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/e20c399a56933cef11171b733fbc008f1e4e9fa8)
12
32
 
13
33
  #### [v2.12.5](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.12.4...v2.12.5)
package/db.js CHANGED
@@ -1,268 +1 @@
1
- import { DatabaseSync } from "node:sqlite";
2
-
3
- export const SCHEMA = `
4
- CREATE TABLE IF NOT EXISTS universes (
5
- universe_id TEXT PRIMARY KEY,
6
- name TEXT NOT NULL
7
- );
8
-
9
- CREATE TABLE IF NOT EXISTS projects (
10
- project_id TEXT PRIMARY KEY,
11
- universe_id TEXT REFERENCES universes(universe_id),
12
- name TEXT NOT NULL
13
- );
14
-
15
- CREATE TABLE IF NOT EXISTS scenes (
16
- scene_id TEXT NOT NULL,
17
- project_id TEXT NOT NULL REFERENCES projects(project_id),
18
- title TEXT,
19
- part INTEGER,
20
- chapter INTEGER,
21
- chapter_title TEXT,
22
- pov TEXT,
23
- logline TEXT,
24
- scene_change TEXT,
25
- causality INTEGER,
26
- stakes INTEGER,
27
- scene_functions TEXT,
28
- save_the_cat_beat TEXT,
29
- timeline_position INTEGER,
30
- story_time TEXT,
31
- word_count INTEGER,
32
- file_path TEXT NOT NULL,
33
- prose_checksum TEXT,
34
- metadata_stale INTEGER NOT NULL DEFAULT 0,
35
- updated_at TEXT NOT NULL,
36
- PRIMARY KEY (scene_id, project_id)
37
- );
38
-
39
- CREATE TABLE IF NOT EXISTS scene_characters (
40
- scene_id TEXT NOT NULL,
41
- character_id TEXT NOT NULL,
42
- PRIMARY KEY (scene_id, character_id)
43
- );
44
-
45
- CREATE TABLE IF NOT EXISTS scene_places (
46
- scene_id TEXT NOT NULL,
47
- place_id TEXT NOT NULL,
48
- PRIMARY KEY (scene_id, place_id)
49
- );
50
-
51
- CREATE TABLE IF NOT EXISTS scene_tags (
52
- scene_id TEXT NOT NULL,
53
- tag TEXT NOT NULL,
54
- PRIMARY KEY (scene_id, tag)
55
- );
56
-
57
- CREATE TABLE IF NOT EXISTS scene_threads (
58
- scene_id TEXT NOT NULL,
59
- thread_id TEXT NOT NULL,
60
- beat TEXT,
61
- PRIMARY KEY (scene_id, thread_id)
62
- );
63
-
64
- CREATE TABLE IF NOT EXISTS characters (
65
- character_id TEXT NOT NULL PRIMARY KEY,
66
- project_id TEXT,
67
- universe_id TEXT,
68
- name TEXT NOT NULL,
69
- role TEXT,
70
- arc_summary TEXT,
71
- first_appearance TEXT,
72
- file_path TEXT
73
- );
74
-
75
- CREATE TABLE IF NOT EXISTS character_traits (
76
- character_id TEXT NOT NULL,
77
- trait TEXT NOT NULL,
78
- PRIMARY KEY (character_id, trait)
79
- );
80
-
81
- CREATE TABLE IF NOT EXISTS character_relationships (
82
- from_character TEXT NOT NULL,
83
- to_character TEXT NOT NULL,
84
- relationship_type TEXT NOT NULL,
85
- strength TEXT,
86
- scene_id TEXT,
87
- note TEXT
88
- );
89
-
90
- CREATE TABLE IF NOT EXISTS places (
91
- place_id TEXT NOT NULL PRIMARY KEY,
92
- project_id TEXT,
93
- universe_id TEXT,
94
- name TEXT NOT NULL,
95
- file_path TEXT
96
- );
97
-
98
- CREATE TABLE IF NOT EXISTS threads (
99
- thread_id TEXT NOT NULL PRIMARY KEY,
100
- project_id TEXT NOT NULL,
101
- name TEXT NOT NULL,
102
- status TEXT NOT NULL DEFAULT 'active'
103
- );
104
-
105
- CREATE TABLE IF NOT EXISTS reference_docs (
106
- doc_id TEXT NOT NULL PRIMARY KEY,
107
- project_id TEXT,
108
- universe_id TEXT,
109
- title TEXT NOT NULL,
110
- file_path TEXT NOT NULL
111
- );
112
-
113
- CREATE TABLE IF NOT EXISTS reference_doc_tags (
114
- doc_id TEXT NOT NULL,
115
- tag TEXT NOT NULL,
116
- PRIMARY KEY (doc_id, tag)
117
- );
118
-
119
- CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
120
- scene_id, project_id, logline, title, keywords
121
- );
122
-
123
- CREATE TABLE IF NOT EXISTS schema_version (
124
- id INTEGER PRIMARY KEY CHECK (id = 1),
125
- version INTEGER NOT NULL
126
- );
127
-
128
- CREATE TABLE IF NOT EXISTS async_jobs (
129
- job_id TEXT NOT NULL PRIMARY KEY,
130
- kind TEXT NOT NULL,
131
- status TEXT NOT NULL,
132
- created_at TEXT NOT NULL,
133
- started_at TEXT,
134
- finished_at TEXT,
135
- error TEXT,
136
- result_json TEXT
137
- );
138
- `;
139
-
140
- // Each function is applied exactly once, in order, when version < its index+1.
141
- // Each migration runs inside a transaction with the version bump — crash-safe.
142
- // Migrations must be idempotent (guard against already-applied state).
143
- // Never edit existing entries — add new ones at the end.
144
- const MIGRATIONS = [
145
- // 1: add chapter_title column to scenes
146
- (db) => {
147
- const sceneColumns = db.prepare(`PRAGMA table_info(scenes)`).all();
148
- if (!sceneColumns.some(c => c.name === "chapter_title")) {
149
- db.exec(`ALTER TABLE scenes ADD COLUMN chapter_title TEXT;`);
150
- }
151
- },
152
- // 2: rebuild FTS table to include keywords column (preserve existing rows)
153
- (db) => {
154
- const ftsSql = db.prepare(`
155
- SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'scenes_fts'
156
- `).get()?.sql;
157
- if (typeof ftsSql === "string" && !ftsSql.toLowerCase().includes("keywords")) {
158
- db.exec(`
159
- CREATE VIRTUAL TABLE scenes_fts_migrating USING fts5(
160
- scene_id, project_id, logline, title, keywords
161
- );
162
- `);
163
- db.exec(`
164
- INSERT INTO scenes_fts_migrating (scene_id, project_id, logline, title, keywords)
165
- SELECT scene_id, project_id, logline, title, ''
166
- FROM scenes_fts;
167
- `);
168
- db.exec(`DROP TABLE scenes_fts;`);
169
- db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
170
- }
171
- },
172
- ];
173
-
174
- // The version every database should reach after openDb. Not the current DB value —
175
- // query schema_version directly if you need the live version of a specific database.
176
- export const CURRENT_SCHEMA_VERSION = MIGRATIONS.length;
177
-
178
- function applyMigrations(db) {
179
- db.prepare(`INSERT OR IGNORE INTO schema_version (id, version) VALUES (1, 0)`).run();
180
- for (;;) {
181
- db.exec(`BEGIN IMMEDIATE;`);
182
- try {
183
- const { version } = db.prepare(`SELECT version FROM schema_version WHERE id = 1`).get();
184
- if (version >= MIGRATIONS.length) {
185
- db.exec(`COMMIT;`);
186
- break;
187
- }
188
- MIGRATIONS[version](db);
189
- // WHERE version = ? ensures the bump is monotonic: a concurrent opener
190
- // that advanced the version first will cause this UPDATE to match 0 rows,
191
- // which is safe — the migration is already applied.
192
- db.prepare(`UPDATE schema_version SET version = ? WHERE id = 1 AND version = ?`).run(version + 1, version);
193
- db.exec(`COMMIT;`);
194
- } catch (err) {
195
- db.exec(`ROLLBACK;`);
196
- throw err;
197
- }
198
- }
199
- }
200
-
201
- export function openDb(dbPath) {
202
- const db = new DatabaseSync(dbPath);
203
- db.exec(SCHEMA);
204
- applyMigrations(db);
205
- return db;
206
- }
207
-
208
- export function checkpointJobCreate(db, job) {
209
- db.prepare(`
210
- INSERT OR IGNORE INTO async_jobs (job_id, kind, status, created_at, started_at)
211
- VALUES (?, ?, ?, ?, ?)
212
- `).run(job.id, job.kind, job.status, job.createdAt, job.startedAt ?? null);
213
- }
214
-
215
- export function checkpointJobFinish(db, job) {
216
- // UPSERT so a terminal state is always recorded even if checkpointJobCreate
217
- // was skipped due to a best-effort failure.
218
- db.prepare(`
219
- INSERT INTO async_jobs
220
- (job_id, kind, status, created_at, started_at, finished_at, error, result_json)
221
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
222
- ON CONFLICT(job_id) DO UPDATE SET
223
- status = excluded.status,
224
- finished_at = excluded.finished_at,
225
- error = excluded.error,
226
- result_json = excluded.result_json
227
- `).run(
228
- job.id,
229
- job.kind,
230
- job.status,
231
- job.createdAt,
232
- job.startedAt ?? null,
233
- job.finishedAt ?? null,
234
- job.error ?? null,
235
- job.result != null ? JSON.stringify(job.result) : null
236
- );
237
- }
238
-
239
- export function pruneJobCheckpoints(db, ttlMs) {
240
- const cutoff = new Date(Date.now() - ttlMs).toISOString();
241
- db.prepare(`
242
- DELETE FROM async_jobs WHERE finished_at IS NOT NULL AND finished_at < ?
243
- `).run(cutoff);
244
- }
245
-
246
- export function loadStalledJobs(db) {
247
- // 'cancelling' included defensively; in practice only 'running' rows exist
248
- // since we never write a 'cancelling' checkpoint between create and finish.
249
- return db.prepare(`
250
- SELECT job_id, kind, status, created_at, started_at
251
- FROM async_jobs WHERE status IN ('running', 'cancelling')
252
- `).all().map(row => ({
253
- id: row.job_id,
254
- kind: row.kind,
255
- status: row.status,
256
- createdAt: row.created_at,
257
- startedAt: row.started_at ?? null,
258
- finishedAt: null,
259
- error: null,
260
- result: null,
261
- progress: null,
262
- child: null,
263
- onComplete: null,
264
- tmpDir: null,
265
- requestPath: null,
266
- resultPath: null,
267
- }));
268
- }
1
+ export * from "./src/core/db.js";
package/git.js CHANGED
@@ -1,209 +1 @@
1
- import { execFileSync, execSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
-
5
- /**
6
- * Check if a directory is itself the root of a git repository (not just inside one).
7
- * This prevents mcp-writing's own .git from being used for prose snapshots when
8
- * WRITING_SYNC_DIR is a subdirectory of the code repo.
9
- */
10
- export function isGitRepository(dirPath) {
11
- try {
12
- const gitRoot = execSync("git rev-parse --show-toplevel", {
13
- cwd: dirPath,
14
- stdio: "pipe",
15
- encoding: "utf8",
16
- }).trim();
17
- return fs.realpathSync(gitRoot) === fs.realpathSync(dirPath);
18
- } catch {
19
- return false;
20
- }
21
- }
22
-
23
- /**
24
- * Initialize a git repository in a directory
25
- */
26
- export function initGitRepository(dirPath) {
27
- try {
28
- execSync("git init", { cwd: dirPath, stdio: "pipe" });
29
- // Set a dummy user config for commits if not already set
30
- try {
31
- execSync("git config user.email", { cwd: dirPath, stdio: "pipe" });
32
- } catch {
33
- execSync("git config user.email writing-mcp@local", { cwd: dirPath, stdio: "pipe" });
34
- execSync("git config user.name writing-mcp", { cwd: dirPath, stdio: "pipe" });
35
- }
36
- return true;
37
- } catch (err) {
38
- throw new Error(`Failed to initialize git repository: ${err.message}`, { cause: err });
39
- }
40
- }
41
-
42
- /**
43
- * Check if git is available on PATH
44
- */
45
- export function isGitAvailable() {
46
- try {
47
- execSync("git --version", { stdio: "pipe" });
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
- /**
55
- * Create a git commit for one or more scene-related files
56
- * Returns { commit_hash: string, commit_message: string }
57
- */
58
- export function createSnapshot(dirPath, filePath, sceneId, instruction, options = {}) {
59
- try {
60
- const { messagePrefix = "pre-edit snapshot" } = options;
61
- const inputPaths = Array.isArray(filePath) ? filePath : [filePath];
62
- const relPaths = [...new Set(inputPaths
63
- .filter(Boolean)
64
- .map(p => path.relative(dirPath, p)))];
65
- if (!relPaths.length) {
66
- throw new Error("No file paths provided for snapshot");
67
- }
68
- // Use -A so removed/renamed paths are staged as part of relocation snapshots.
69
- execFileSync("git", ["add", "-A", "--", ...relPaths], { cwd: dirPath, stdio: "pipe" });
70
-
71
- const commitMessage = messagePrefix
72
- ? `${messagePrefix}: ${sceneId} — ${instruction}`
73
- : String(instruction);
74
-
75
- execFileSync("git", ["commit", "-m", commitMessage], {
76
- cwd: dirPath,
77
- stdio: "pipe",
78
- });
79
- const commitHash = execFileSync("git", ["rev-parse", "HEAD"], {
80
- cwd: dirPath,
81
- stdio: "pipe",
82
- encoding: "utf8",
83
- }).trim();
84
-
85
- return {
86
- commit_hash: commitHash,
87
- commit_message: commitMessage,
88
- };
89
- } catch (err) {
90
- // Check if nothing changed (no error, just no commit)
91
- const stderr = err?.stderr ? String(err.stderr) : "";
92
- const stdout = err?.stdout ? String(err.stdout) : "";
93
- const text = `${stderr}\n${stdout}\n${err?.message ?? ""}`;
94
- if (text.includes("nothing to commit") || err.status === 1) {
95
- return {
96
- commit_hash: null,
97
- commit_message: null,
98
- reason: "no changes to commit",
99
- };
100
- }
101
- throw new Error(`Failed to create snapshot: ${err.message}`, { cause: err });
102
- }
103
- }
104
-
105
- /**
106
- * List git commits for a file, with timestamps and messages
107
- * Returns array of { commit_hash, timestamp, message }
108
- */
109
- export function listSnapshots(dirPath, filePath) {
110
- try {
111
- const relPath = path.relative(dirPath, filePath);
112
- const output = execFileSync(
113
- "git",
114
- ["log", "--pretty=format:%h|%ai|%s", "--", relPath],
115
- {
116
- cwd: dirPath,
117
- stdio: "pipe",
118
- encoding: "utf8",
119
- }
120
- );
121
-
122
- if (!output) return [];
123
-
124
- return output
125
- .split("\n")
126
- .filter(Boolean)
127
- .map((line) => {
128
- const [hash, timestamp, ...messageParts] = line.split("|");
129
- return {
130
- commit_hash: hash.trim(),
131
- timestamp: timestamp.trim(),
132
- message: messageParts.join("|").trim(),
133
- };
134
- });
135
- } catch (err) {
136
- // If there are no commits yet, return empty array
137
- if (err.message.includes("your current branch") || err.status === 128) {
138
- return [];
139
- }
140
- throw new Error(`Failed to list snapshots: ${err.message}`, { cause: err });
141
- }
142
- }
143
-
144
- /**
145
- * Get prose content from a specific git commit
146
- * If commit is null, returns current working tree version
147
- */
148
- export function getSceneProseAtCommit(dirPath, filePath, commitHash) {
149
- try {
150
- const relPath = path.relative(dirPath, filePath);
151
-
152
- if (!commitHash) {
153
- // Return current working tree version
154
- return fs.readFileSync(filePath, "utf8");
155
- }
156
-
157
- // Get version from git
158
- const content = execFileSync("git", ["show", `${commitHash}:${relPath}`], {
159
- cwd: dirPath,
160
- stdio: "pipe",
161
- encoding: "utf8",
162
- });
163
-
164
- return content;
165
- } catch (err) {
166
- // ENOENT from execFileSync means the git binary was not found on PATH — not a missing file in the commit.
167
- if (err.code === "ENOENT") {
168
- throw new Error("git executable not found; ensure git is installed and on PATH", { cause: err });
169
- }
170
- // git exit 128 with "path ... does not exist in" or "bad object" indicates missing path/commit.
171
- const stderr = err?.stderr ? String(err.stderr) : "";
172
- if (err.status === 128 && (stderr.includes("does not exist in") || stderr.includes("bad object"))) {
173
- throw new Error(`File not found in commit ${commitHash}`, { cause: err });
174
- }
175
- throw new Error(`Failed to retrieve scene prose: ${err.message}`, { cause: err });
176
- }
177
- }
178
-
179
- /**
180
- * Check if working tree is clean (no uncommitted changes)
181
- */
182
- export function isWorkingTreeClean(dirPath) {
183
- try {
184
- const output = execSync("git status --porcelain", {
185
- cwd: dirPath,
186
- stdio: "pipe",
187
- encoding: "utf8",
188
- });
189
- return output.trim() === "";
190
- } catch {
191
- return false;
192
- }
193
- }
194
-
195
- /**
196
- * Get the HEAD commit hash
197
- */
198
- export function getHeadCommitHash(dirPath) {
199
- try {
200
- const hash = execSync("git rev-parse HEAD", {
201
- cwd: dirPath,
202
- stdio: "pipe",
203
- encoding: "utf8",
204
- }).trim();
205
- return hash;
206
- } catch {
207
- return null;
208
- }
209
- }
1
+ export * from "./src/core/git.js";
package/helpers.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  renderPlaceSheetTemplate,
10
10
  renderCharacterArcTemplate,
11
11
  } from "./src/world/world-entity-templates.js";
12
- import { ReviewBundlePlanError } from "./review-bundles.js";
12
+ import { ReviewBundlePlanError } from "./src/review-bundles/review-bundles.js";
13
13
 
14
14
  export function deriveLoglineFromProse(prose) {
15
15
  const compact = prose.replace(/\s+/g, " ").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.12.6",
3
+ "version": "2.12.8",
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",
@@ -1,125 +1 @@
1
- function detectQuotationStyle(prose) {
2
- const counts = {
3
- double: (prose.match(/"[^"]{2,}"/g) ?? []).length,
4
- guillemets: (prose.match(/«[^»]{2,}»/g) ?? []).length,
5
- low9: (prose.match(/„[^“]{2,}“/g) ?? []).length,
6
- corner_brackets: (prose.match(/「[^」]{2,}」/g) ?? []).length,
7
- dialogue_dash_en: (prose.match(/^\s*–\s/mg) ?? []).length,
8
- dialogue_dash_em: (prose.match(/^\s*—\s/mg) ?? []).length,
9
- single: (prose.match(/'[^'\n]{2,}'/g) ?? []).length,
10
- };
11
-
12
- let best = null;
13
- let bestCount = 0;
14
- for (const [style, count] of Object.entries(counts)) {
15
- if (count > bestCount) {
16
- best = style;
17
- bestCount = count;
18
- }
19
- }
20
- return bestCount > 0 ? best : null;
21
- }
22
-
23
- function detectEmDashSpacing(prose) {
24
- const spaced = (prose.match(/\s—\s/g) ?? []).length;
25
- const closed = (prose.match(/\S—\S/g) ?? []).length;
26
- if (spaced === 0 && closed === 0) return null;
27
- return spaced >= closed ? "spaced" : "closed";
28
- }
29
-
30
- function detectSpellingVariant(prose) {
31
- const lower = prose.toLowerCase();
32
- const ukSignals = ["colour", "realise", "centre", "honour", "travelling"];
33
- const usSignals = ["color", "realize", "center", "honor", "traveling"];
34
-
35
- const countHits = (signals) => signals.reduce((sum, word) => {
36
- const re = new RegExp(`\\b${word}\\b`, "g");
37
- return sum + (lower.match(re) ?? []).length;
38
- }, 0);
39
-
40
- const uk = countHits(ukSignals);
41
- const us = countHits(usSignals);
42
- if (uk === 0 && us === 0) return null;
43
- return uk >= us ? "uk" : "us";
44
- }
45
-
46
- function detectTenseHint(prose) {
47
- const lower = prose.toLowerCase();
48
- const past = (lower.match(/\b(was|were|had|did)\b/g) ?? []).length;
49
- const present = (lower.match(/\b(is|are|has|do)\b/g) ?? []).length;
50
- if (past === 0 && present === 0) return null;
51
- return present >= past ? "present" : "past";
52
- }
53
-
54
- function mostCommonValue(values) {
55
- const counts = new Map();
56
- for (const value of values) {
57
- if (!value) continue;
58
- counts.set(value, (counts.get(value) ?? 0) + 1);
59
- }
60
- if (counts.size === 0) return null;
61
-
62
- let bestValue = null;
63
- let bestCount = 0;
64
- for (const [value, count] of counts.entries()) {
65
- if (count > bestCount) {
66
- bestValue = value;
67
- bestCount = count;
68
- }
69
- }
70
- return { value: bestValue, count: bestCount, total: values.filter(Boolean).length };
71
- }
72
-
73
- export function detectStyleguideSignals(prose) {
74
- return {
75
- quotation_style: detectQuotationStyle(prose),
76
- em_dash_spacing: detectEmDashSpacing(prose),
77
- spelling: detectSpellingVariant(prose),
78
- tense: detectTenseHint(prose),
79
- };
80
- }
81
-
82
- export function analyzeSceneStyleguideDrift({ prose, resolvedConfig }) {
83
- const observed = detectStyleguideSignals(prose);
84
- const drift = [];
85
-
86
- for (const field of ["quotation_style", "em_dash_spacing", "spelling", "tense"]) {
87
- const declared = resolvedConfig?.[field];
88
- const seen = observed[field];
89
- if (!declared || !seen) continue;
90
- if (declared !== seen) {
91
- drift.push({ field, declared, observed: seen });
92
- }
93
- }
94
-
95
- return { observed, drift };
96
- }
97
-
98
- export function suggestStyleguideUpdatesFromScenes({
99
- sceneAnalyses,
100
- resolvedConfig,
101
- minAgreement = 0.6,
102
- minEvidence = 3,
103
- }) {
104
- const suggestions = {};
105
-
106
- for (const field of ["quotation_style", "em_dash_spacing", "spelling", "tense"]) {
107
- const values = sceneAnalyses.map((scene) => scene.observed?.[field] ?? null);
108
- const common = mostCommonValue(values);
109
- if (!common) continue;
110
- if (common.total < minEvidence) continue;
111
-
112
- const agreement = common.total > 0 ? common.count / common.total : 0;
113
- const fieldThreshold = field === "tense" ? Math.max(minAgreement, 0.75) : minAgreement;
114
- if (agreement < fieldThreshold) continue;
115
- if (resolvedConfig?.[field] === common.value) continue;
116
-
117
- suggestions[field] = {
118
- suggested_value: common.value,
119
- agreement,
120
- based_on_scenes: common.total,
121
- };
122
- }
123
-
124
- return suggestions;
125
- }
1
+ export * from "./src/styleguide/prose-styleguide-drift.js";