ava-langgraph-narrative-intelligence 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +268 -0
  2. package/dist/graphs/index.cjs +1511 -0
  3. package/dist/graphs/index.cjs.map +1 -0
  4. package/dist/graphs/index.d.cts +2 -0
  5. package/dist/graphs/index.d.ts +2 -0
  6. package/dist/graphs/index.js +1468 -0
  7. package/dist/graphs/index.js.map +1 -0
  8. package/dist/index-Btxk3nQm.d.cts +430 -0
  9. package/dist/index-CgXXxuIH.d.ts +430 -0
  10. package/dist/index-CweT-D3c.d.cts +122 -0
  11. package/dist/index-D-zWH42e.d.cts +66 -0
  12. package/dist/index-D71kh3nE.d.cts +213 -0
  13. package/dist/index-DApls3w2.d.ts +66 -0
  14. package/dist/index-UamXITgg.d.ts +122 -0
  15. package/dist/index-v9AlRC0M.d.ts +213 -0
  16. package/dist/index.cjs +2753 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +6 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +2654 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integrations/index.cjs +654 -0
  23. package/dist/integrations/index.cjs.map +1 -0
  24. package/dist/integrations/index.d.cts +2 -0
  25. package/dist/integrations/index.d.ts +2 -0
  26. package/dist/integrations/index.js +614 -0
  27. package/dist/integrations/index.js.map +1 -0
  28. package/dist/ncp-tXS9Jr9e.d.cts +132 -0
  29. package/dist/ncp-tXS9Jr9e.d.ts +132 -0
  30. package/dist/nodes/index.cjs +226 -0
  31. package/dist/nodes/index.cjs.map +1 -0
  32. package/dist/nodes/index.d.cts +2 -0
  33. package/dist/nodes/index.d.ts +2 -0
  34. package/dist/nodes/index.js +196 -0
  35. package/dist/nodes/index.js.map +1 -0
  36. package/dist/schemas/index.cjs +550 -0
  37. package/dist/schemas/index.cjs.map +1 -0
  38. package/dist/schemas/index.d.cts +2 -0
  39. package/dist/schemas/index.d.ts +2 -0
  40. package/dist/schemas/index.js +484 -0
  41. package/dist/schemas/index.js.map +1 -0
  42. package/dist/unified_state_bridge-CIDm1kuf.d.cts +266 -0
  43. package/dist/unified_state_bridge-CIDm1kuf.d.ts +266 -0
  44. package/package.json +91 -0
  45. package/src/graphs/coherence_engine.ts +1027 -0
  46. package/src/graphs/index.ts +47 -0
  47. package/src/graphs/three_universe_processor.ts +1136 -0
  48. package/src/index.ts +181 -0
  49. package/src/integrations/index.ts +17 -0
  50. package/src/integrations/redis_state.ts +691 -0
  51. package/src/nodes/emotional_classifier.ts +289 -0
  52. package/src/nodes/index.ts +17 -0
  53. package/src/schemas/index.ts +75 -0
  54. package/src/schemas/ncp.ts +312 -0
  55. package/src/schemas/unified_state_bridge.ts +681 -0
  56. package/src/tests/coherence_engine.test.ts +273 -0
  57. package/src/tests/three_universe_processor.test.ts +309 -0
  58. package/src/tests/unified_state_bridge.test.ts +360 -0
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * Narrative Coherence Engine
3
+ *
4
+ * Analyzes narrative coherence and identifies gaps.
5
+ * This is a core dependency for the Editor Anvil app.
6
+ *
7
+ * Features:
8
+ * - Gap identification (structural, thematic, character, sensory, continuity)
9
+ * - Coherence scoring across multiple dimensions
10
+ * - Enrichment routing suggestions
11
+ * - Trinity perspective integration (Mia/Miette/Ava8)
12
+ */
13
+
14
+ import {
15
+ StoryBeat,
16
+ CharacterState,
17
+ ThematicThread,
18
+ } from "../schemas/unified_state_bridge.js";
19
+
20
+ /**
21
+ * Types of narrative gaps that can be identified.
22
+ */
23
+ export enum GapType {
24
+ STRUCTURAL = "structural", // Missing beats, incomplete arcs
25
+ THEMATIC = "thematic", // Promised themes underdelivered
26
+ CHARACTER = "character", // Traits mentioned but not demonstrated
27
+ SENSORY = "sensory", // Scenes lacking grounding detail
28
+ CONTINUITY = "continuity", // Timeline/detail inconsistencies
29
+ }
30
+
31
+ /**
32
+ * Severity levels for identified gaps.
33
+ */
34
+ export enum GapSeverity {
35
+ CRITICAL = "critical", // Must fix before publication
36
+ MODERATE = "moderate", // Should address in next pass
37
+ MINOR = "minor", // Nice to have, low priority
38
+ }
39
+
40
+ /**
41
+ * Where to route gaps for remediation.
42
+ */
43
+ export enum RoutingTarget {
44
+ STORYTELLER = "storyteller", // Needs prose refinement
45
+ STRUCTURIST = "structurist", // Needs structural repair
46
+ ARCHITECT = "architect", // Schema inconsistency
47
+ AUTHOR = "author", // Human decision required
48
+ }
49
+
50
+ /**
51
+ * Component score status.
52
+ */
53
+ export type ComponentStatus = "good" | "warning" | "critical";
54
+
55
+ /**
56
+ * A narrative gap identified in the story.
57
+ */
58
+ export interface Gap {
59
+ id: string;
60
+ gapType: GapType;
61
+ severity: GapSeverity;
62
+ description: string;
63
+ location: Record<string, unknown>; // beat_id, chapter_id, position
64
+ suggestedRoute: RoutingTarget;
65
+ resolved: boolean;
66
+ resolution?: string;
67
+ }
68
+
69
+ /**
70
+ * Create a Gap with defaults
71
+ */
72
+ export function createGap(
73
+ id: string,
74
+ gapType: GapType,
75
+ severity: GapSeverity,
76
+ description: string,
77
+ suggestedRoute: RoutingTarget,
78
+ options: Partial<Gap> = {}
79
+ ): Gap {
80
+ return {
81
+ id,
82
+ gapType,
83
+ severity,
84
+ description,
85
+ location: options.location ?? {},
86
+ suggestedRoute,
87
+ resolved: options.resolved ?? false,
88
+ resolution: options.resolution,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Score for a single coherence component.
94
+ */
95
+ export interface ComponentScore {
96
+ score: number; // 0-100
97
+ status: ComponentStatus;
98
+ issues: string[];
99
+ suggestions: string[];
100
+ }
101
+
102
+ /**
103
+ * Create a ComponentScore with defaults
104
+ */
105
+ export function createComponentScore(
106
+ score: number,
107
+ status: ComponentStatus,
108
+ options: Partial<ComponentScore> = {}
109
+ ): ComponentScore {
110
+ return {
111
+ score,
112
+ status,
113
+ issues: options.issues ?? [],
114
+ suggestions: options.suggestions ?? [],
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Complete coherence score for a narrative.
120
+ */
121
+ export interface CoherenceScore {
122
+ overall: number;
123
+ narrativeFlow: ComponentScore;
124
+ characterConsistency: ComponentScore;
125
+ pacing: ComponentScore;
126
+ themeSaturation: ComponentScore;
127
+ continuity: ComponentScore;
128
+ analyzedAt: string;
129
+ }
130
+
131
+ /**
132
+ * Create a CoherenceScore
133
+ */
134
+ export function createCoherenceScore(
135
+ overall: number,
136
+ narrativeFlow: ComponentScore,
137
+ characterConsistency: ComponentScore,
138
+ pacing: ComponentScore,
139
+ themeSaturation: ComponentScore,
140
+ continuity: ComponentScore
141
+ ): CoherenceScore {
142
+ return {
143
+ overall,
144
+ narrativeFlow,
145
+ characterConsistency,
146
+ pacing,
147
+ themeSaturation,
148
+ continuity,
149
+ analyzedAt: new Date().toISOString(),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Assessment from three narrative perspectives (Mia/Miette/Ava8).
155
+ */
156
+ export interface TrinityAssessment {
157
+ mia: string; // Structural quality (🧠 logical, analytical)
158
+ miette: string; // Emotional effectiveness (🌸 feeling, resonance)
159
+ ava8: string; // Atmospheric/sensory (🎨 visual, immersive)
160
+ priorities: string[];
161
+ }
162
+
163
+ /**
164
+ * Create a TrinityAssessment
165
+ */
166
+ export function createTrinityAssessment(
167
+ mia: string,
168
+ miette: string,
169
+ ava8: string,
170
+ priorities: string[] = []
171
+ ): TrinityAssessment {
172
+ return { mia, miette, ava8, priorities };
173
+ }
174
+
175
+ /**
176
+ * Type alias for the coherence engine state.
177
+ */
178
+ export interface CoherenceEngineState {
179
+ beats: StoryBeat[];
180
+ characters: CharacterState[];
181
+ themes: ThematicThread[];
182
+
183
+ // Component scores
184
+ narrativeFlowScore?: ComponentScore;
185
+ characterConsistencyScore?: ComponentScore;
186
+ pacingScore?: ComponentScore;
187
+ themeSaturationScore?: ComponentScore;
188
+ continuityScore?: ComponentScore;
189
+
190
+ // Overall
191
+ overallScore?: number;
192
+ gaps?: Gap[];
193
+ trinityAssessment?: TrinityAssessment;
194
+ coherenceScore?: CoherenceScore;
195
+ }
196
+
197
+ /**
198
+ * Result of coherence analysis.
199
+ */
200
+ export interface CoherenceResult {
201
+ coherenceScore: CoherenceScore;
202
+ gaps: Gap[];
203
+ trinityAssessment: TrinityAssessment;
204
+ }
205
+
206
+ /**
207
+ * Analyzes narrative coherence and identifies gaps.
208
+ *
209
+ * This is a core component for the Editor Anvil app, providing:
210
+ * - Comprehensive coherence scoring across 5 dimensions
211
+ * - Gap identification with severity and routing
212
+ * - Trinity perspective assessment (Mia/Miette/Ava8)
213
+ * - Actionable improvement suggestions
214
+ *
215
+ * @example
216
+ * const engine = new NarrativeCoherenceEngine();
217
+ * const result = engine.analyze(beats, characters, themes);
218
+ *
219
+ * // Access scores
220
+ * console.log(`Overall coherence: ${result.coherenceScore.overall}`);
221
+ *
222
+ * // Access gaps
223
+ * for (const gap of result.gaps) {
224
+ * console.log(`Gap: ${gap.description} (${gap.severity})`);
225
+ * }
226
+ *
227
+ * // Access Trinity assessment
228
+ * console.log(`Mia says: ${result.trinityAssessment.mia}`);
229
+ */
230
+ export class NarrativeCoherenceEngine {
231
+ private strictMode: boolean;
232
+ private gapCounter: number;
233
+
234
+ constructor(options: { strictMode?: boolean } = {}) {
235
+ this.strictMode = options.strictMode ?? false;
236
+ this.gapCounter = 0;
237
+ }
238
+
239
+ private generateGapId(): string {
240
+ this.gapCounter += 1;
241
+ return `gap_${this.gapCounter}`;
242
+ }
243
+
244
+ /**
245
+ * Analyze narrative flow - how smoothly the story progresses.
246
+ *
247
+ * Checks:
248
+ * - Beat transitions (jarring vs smooth)
249
+ * - Logical causality between beats
250
+ * - Pacing consistency
251
+ */
252
+ private analyzeNarrativeFlow(state: CoherenceEngineState): CoherenceEngineState {
253
+ const beats = state.beats;
254
+ const issues: string[] = [];
255
+ const suggestions: string[] = [];
256
+ let score: number;
257
+ let status: ComponentStatus;
258
+
259
+ if (beats.length < 2) {
260
+ score = 50.0;
261
+ issues.push("Too few beats to assess flow");
262
+ suggestions.push("Add more story beats to establish narrative rhythm");
263
+ } else {
264
+ // Check for logical function progression
265
+ const functions = beats.map((b) => b.narrativeFunction);
266
+
267
+ // Penalize if no setup before confrontation
268
+ let hasProperStructure = false;
269
+ for (let i = 0; i < functions.length; i++) {
270
+ const func = functions[i];
271
+ if (["setup", "introduction", "discovery"].includes(func)) {
272
+ hasProperStructure = true;
273
+ break;
274
+ } else if (["confrontation", "crisis", "climax"].includes(func)) {
275
+ if (!hasProperStructure) {
276
+ issues.push(`Beat ${i + 1} escalates without proper setup`);
277
+ hasProperStructure = true; // Only report once
278
+ }
279
+ }
280
+ }
281
+
282
+ // Check emotional continuity
283
+ let prevTone: string | undefined;
284
+ let jarringTransitions = 0;
285
+
286
+ for (let i = 0; i < beats.length; i++) {
287
+ const beat = beats[i];
288
+ if (prevTone && beat.emotionalTone) {
289
+ // Simple check: devastation followed by joy is jarring
290
+ const jarringPairs: [string, string][] = [
291
+ ["devastating", "joyful"],
292
+ ["fearful", "peaceful"],
293
+ ["triumphant", "devastating"],
294
+ ];
295
+ for (const [p1, p2] of jarringPairs) {
296
+ if (
297
+ (prevTone.toLowerCase().includes(p1) &&
298
+ beat.emotionalTone.toLowerCase().includes(p2)) ||
299
+ (prevTone.toLowerCase().includes(p2) &&
300
+ beat.emotionalTone.toLowerCase().includes(p1))
301
+ ) {
302
+ jarringTransitions += 1;
303
+ issues.push(`Jarring emotional transition at Beat ${i + 1}`);
304
+ }
305
+ }
306
+ }
307
+ prevTone = beat.emotionalTone;
308
+ }
309
+
310
+ // Calculate score
311
+ let baseScore = 85.0;
312
+ baseScore -= jarringTransitions * 10;
313
+ baseScore -=
314
+ issues.filter((i) => i.includes("without proper setup")).length * 15;
315
+
316
+ score = Math.max(0.0, Math.min(100.0, baseScore));
317
+
318
+ if (jarringTransitions > 0) {
319
+ suggestions.push("Add transitional beats to smooth emotional shifts");
320
+ }
321
+ if (!hasProperStructure) {
322
+ suggestions.push(
323
+ "Consider adding setup beats before major confrontations"
324
+ );
325
+ }
326
+ }
327
+
328
+ // Determine status
329
+ if (score >= 70) {
330
+ status = "good";
331
+ } else if (score >= 50) {
332
+ status = "warning";
333
+ } else {
334
+ status = "critical";
335
+ }
336
+
337
+ state.narrativeFlowScore = createComponentScore(score, status, {
338
+ issues,
339
+ suggestions,
340
+ });
341
+
342
+ return state;
343
+ }
344
+
345
+ /**
346
+ * Analyze character consistency across the narrative.
347
+ *
348
+ * Checks:
349
+ * - Character voice consistency
350
+ * - Arc progression logic
351
+ * - Relationship evolution coherence
352
+ */
353
+ private analyzeCharacterConsistency(
354
+ state: CoherenceEngineState
355
+ ): CoherenceEngineState {
356
+ const beats = state.beats;
357
+ const characters = state.characters;
358
+ const issues: string[] = [];
359
+ const suggestions: string[] = [];
360
+ let score: number;
361
+ let status: ComponentStatus;
362
+
363
+ if (characters.length === 0) {
364
+ score = 50.0;
365
+ issues.push("No character data provided");
366
+ suggestions.push("Define character states to enable consistency analysis");
367
+ } else {
368
+ // Track character appearances across beats
369
+ const characterBeats: Record<string, number[]> = {};
370
+ for (const char of characters) {
371
+ characterBeats[char.id] = [];
372
+ }
373
+
374
+ for (let i = 0; i < beats.length; i++) {
375
+ const beat = beats[i];
376
+ if (beat.characterId && characterBeats[beat.characterId]) {
377
+ characterBeats[beat.characterId].push(i);
378
+ }
379
+ }
380
+
381
+ // Check for characters with large gaps
382
+ for (const [charId, appearances] of Object.entries(characterBeats)) {
383
+ if (appearances.length >= 2) {
384
+ for (let i = 1; i < appearances.length; i++) {
385
+ const gap = appearances[i] - appearances[i - 1];
386
+ if (gap > 5) {
387
+ // More than 5 beats between appearances
388
+ const char = characters.find((c) => c.id === charId);
389
+ const name = char?.name || charId;
390
+ issues.push(`Character '${name}' disappears for ${gap} beats`);
391
+ suggestions.push(
392
+ `Consider adding '${name}' to beats between ${appearances[i - 1] + 1} and ${appearances[i] + 1}`
393
+ );
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ // Check arc progression
400
+ for (const char of characters) {
401
+ if (char.arcPosition < 0.1 && beats.length > 5) {
402
+ issues.push(`Character '${char.name}' has minimal arc progression`);
403
+ }
404
+ }
405
+
406
+ // Calculate score based on issues
407
+ let baseScore = 90.0;
408
+ baseScore -= issues.filter((i) => i.includes("disappears")).length * 8;
409
+ baseScore -= issues.filter((i) => i.includes("minimal arc")).length * 12;
410
+
411
+ score = Math.max(0.0, Math.min(100.0, baseScore));
412
+ }
413
+
414
+ // Determine status
415
+ if (score >= 70) {
416
+ status = "good";
417
+ } else if (score >= 50) {
418
+ status = "warning";
419
+ } else {
420
+ status = "critical";
421
+ }
422
+
423
+ state.characterConsistencyScore = createComponentScore(score, status, {
424
+ issues,
425
+ suggestions,
426
+ });
427
+
428
+ return state;
429
+ }
430
+
431
+ /**
432
+ * Analyze narrative pacing.
433
+ *
434
+ * Checks:
435
+ * - Tension/relief distribution
436
+ * - Beat density per section
437
+ * - Climax positioning
438
+ */
439
+ private analyzePacing(state: CoherenceEngineState): CoherenceEngineState {
440
+ const beats = state.beats;
441
+ const issues: string[] = [];
442
+ const suggestions: string[] = [];
443
+ let score: number;
444
+ let status: ComponentStatus;
445
+
446
+ if (beats.length < 3) {
447
+ score = 50.0;
448
+ issues.push("Too few beats to assess pacing");
449
+ suggestions.push("Add more beats to establish proper pacing rhythm");
450
+ } else {
451
+ // Analyze function distribution
452
+ const functions = beats.map((b) => b.narrativeFunction.toLowerCase());
453
+
454
+ // Check for climax positioning (should be in last third)
455
+ const climaxPositions = functions
456
+ .map((f, i) => (f.includes("climax") ? i : -1))
457
+ .filter((i) => i >= 0);
458
+
459
+ if (climaxPositions.length === 0) {
460
+ issues.push("No climax beat identified");
461
+ suggestions.push("Ensure at least one beat has a climax function");
462
+ } else {
463
+ // Check if climax is too early
464
+ const lastClimax = climaxPositions[climaxPositions.length - 1];
465
+ const total = beats.length;
466
+ if (lastClimax < total * 0.5) {
467
+ issues.push("Climax occurs too early in the narrative");
468
+ suggestions.push(
469
+ "Move climax to later in the story or add post-climax resolution beats"
470
+ );
471
+ }
472
+ }
473
+
474
+ // Check for consecutive high-tension beats
475
+ const highTensionFuncs = [
476
+ "confrontation",
477
+ "crisis",
478
+ "climax",
479
+ "revelation",
480
+ ];
481
+ let consecutiveHigh = 0;
482
+ let maxConsecutive = 0;
483
+
484
+ for (const func of functions) {
485
+ if (highTensionFuncs.some((ht) => func.includes(ht))) {
486
+ consecutiveHigh += 1;
487
+ maxConsecutive = Math.max(maxConsecutive, consecutiveHigh);
488
+ } else {
489
+ consecutiveHigh = 0;
490
+ }
491
+ }
492
+
493
+ if (maxConsecutive > 3) {
494
+ issues.push(
495
+ `Found ${maxConsecutive} consecutive high-tension beats`
496
+ );
497
+ suggestions.push(
498
+ "Add breathing room with quieter beats between intense moments"
499
+ );
500
+ }
501
+
502
+ // Calculate score
503
+ let baseScore = 85.0;
504
+ if (climaxPositions.length === 0) {
505
+ baseScore -= 20;
506
+ } else if (climaxPositions[climaxPositions.length - 1] < beats.length * 0.5) {
507
+ baseScore -= 15;
508
+ }
509
+ baseScore -= Math.min(20, maxConsecutive * 5);
510
+
511
+ score = Math.max(0.0, Math.min(100.0, baseScore));
512
+ }
513
+
514
+ // Determine status
515
+ if (score >= 70) {
516
+ status = "good";
517
+ } else if (score >= 50) {
518
+ status = "warning";
519
+ } else {
520
+ status = "critical";
521
+ }
522
+
523
+ state.pacingScore = createComponentScore(score, status, {
524
+ issues,
525
+ suggestions,
526
+ });
527
+
528
+ return state;
529
+ }
530
+
531
+ /**
532
+ * Analyze how well themes permeate the narrative.
533
+ *
534
+ * Checks:
535
+ * - Theme presence across beats
536
+ * - Theme introduction and payoff
537
+ * - Theme strength consistency
538
+ */
539
+ private analyzeThemeSaturation(
540
+ state: CoherenceEngineState
541
+ ): CoherenceEngineState {
542
+ const beats = state.beats;
543
+ const themes = state.themes;
544
+ const issues: string[] = [];
545
+ const suggestions: string[] = [];
546
+ let score: number;
547
+ let status: ComponentStatus;
548
+
549
+ if (themes.length === 0) {
550
+ score = 50.0;
551
+ issues.push("No themes defined");
552
+ suggestions.push(
553
+ "Define thematic threads to enable saturation analysis"
554
+ );
555
+ } else {
556
+ // Track theme presence
557
+ const themeCoverage: Record<string, number> = {};
558
+
559
+ for (const theme of themes) {
560
+ // Calculate theme presence across beats
561
+ let beatsWithTheme = 0;
562
+ for (const beat of beats) {
563
+ if (beat.thematicTags?.includes(theme.id)) {
564
+ beatsWithTheme += 1;
565
+ }
566
+ }
567
+
568
+ const coverage = beatsWithTheme / Math.max(beats.length, 1);
569
+ themeCoverage[theme.name] = coverage;
570
+
571
+ // Check for underdeveloped themes
572
+ if (coverage < 0.2 && theme.strength > 0.5) {
573
+ issues.push(
574
+ `Theme '${theme.name}' is important but appears rarely`
575
+ );
576
+ suggestions.push(
577
+ `Weave '${theme.name}' into more beats to fulfill its promise`
578
+ );
579
+ }
580
+
581
+ // Check for theme that appears but never pays off
582
+ if (coverage > 0.3 && theme.strength < 0.3) {
583
+ issues.push(
584
+ `Theme '${theme.name}' appears often but lacks impact`
585
+ );
586
+ suggestions.push(
587
+ `Strengthen the thematic weight of '${theme.name}' in key beats`
588
+ );
589
+ }
590
+ }
591
+
592
+ // Calculate average coverage
593
+ const coverageValues = Object.values(themeCoverage);
594
+ const avgCoverage =
595
+ coverageValues.reduce((a, b) => a + b, 0) /
596
+ Math.max(coverageValues.length, 1);
597
+
598
+ // Score based on coverage and issues
599
+ let baseScore = Math.min(100.0, avgCoverage * 100 + 20); // Base on coverage + buffer
600
+ baseScore -= issues.filter((i) => i.includes("rarely")).length * 10;
601
+ baseScore -= issues.filter((i) => i.includes("lacks impact")).length * 8;
602
+
603
+ score = Math.max(0.0, Math.min(100.0, baseScore));
604
+ }
605
+
606
+ // Determine status
607
+ if (score >= 70) {
608
+ status = "good";
609
+ } else if (score >= 50) {
610
+ status = "warning";
611
+ } else {
612
+ status = "critical";
613
+ }
614
+
615
+ state.themeSaturationScore = createComponentScore(score, status, {
616
+ issues,
617
+ suggestions,
618
+ });
619
+
620
+ return state;
621
+ }
622
+
623
+ /**
624
+ * Analyze narrative continuity.
625
+ *
626
+ * Checks:
627
+ * - Timeline consistency
628
+ * - Detail consistency across beats
629
+ * - Setting/location coherence
630
+ */
631
+ private analyzeContinuity(state: CoherenceEngineState): CoherenceEngineState {
632
+ const beats = state.beats;
633
+ const issues: string[] = [];
634
+ const suggestions: string[] = [];
635
+ let score: number;
636
+ let status: ComponentStatus;
637
+
638
+ if (beats.length < 2) {
639
+ score = 70.0; // Default to passing if not enough to analyze
640
+ issues.push("Too few beats for continuity analysis");
641
+ } else {
642
+ // Check sequence ordering
643
+ const sequences = beats.map((b) => b.sequence);
644
+ const sortedSequences = [...sequences].sort((a, b) => a - b);
645
+
646
+ if (JSON.stringify(sequences) !== JSON.stringify(sortedSequences)) {
647
+ issues.push("Beat sequences are not in order");
648
+ suggestions.push("Reorder beats to ensure logical sequence progression");
649
+ }
650
+
651
+ // Check for duplicate sequences
652
+ if (sequences.length !== new Set(sequences).size) {
653
+ issues.push("Duplicate beat sequence numbers found");
654
+ suggestions.push("Ensure each beat has a unique sequence number");
655
+ }
656
+
657
+ // Check for gaps in sequence
658
+ const maxSeq = Math.max(...sequences);
659
+ const expected = new Set(
660
+ Array.from({ length: maxSeq }, (_, i) => i + 1)
661
+ );
662
+ const actual = new Set(sequences);
663
+ const missing = [...expected].filter((x) => !actual.has(x));
664
+
665
+ if (missing.length > 0 && missing.length <= 3) {
666
+ // Small gaps are issues
667
+ issues.push(`Missing beat sequences: ${missing.sort().join(", ")}`);
668
+ suggestions.push("Fill in missing beat sequences or renumber");
669
+ }
670
+
671
+ // Calculate score
672
+ let baseScore = 90.0;
673
+ baseScore -= issues.filter((i) => i.includes("not in order")).length * 20;
674
+ baseScore -= issues.filter((i) => i.includes("Duplicate")).length * 15;
675
+ baseScore -= issues.filter((i) => i.includes("Missing")).length * 5;
676
+
677
+ score = Math.max(0.0, Math.min(100.0, baseScore));
678
+ }
679
+
680
+ // Determine status
681
+ if (score >= 70) {
682
+ status = "good";
683
+ } else if (score >= 50) {
684
+ status = "warning";
685
+ } else {
686
+ status = "critical";
687
+ }
688
+
689
+ state.continuityScore = createComponentScore(score, status, {
690
+ issues,
691
+ suggestions,
692
+ });
693
+
694
+ return state;
695
+ }
696
+
697
+ /**
698
+ * Calculate the overall coherence score from components.
699
+ */
700
+ private calculateOverallScore(
701
+ state: CoherenceEngineState
702
+ ): CoherenceEngineState {
703
+ const components = [
704
+ state.narrativeFlowScore,
705
+ state.characterConsistencyScore,
706
+ state.pacingScore,
707
+ state.themeSaturationScore,
708
+ state.continuityScore,
709
+ ];
710
+
711
+ const validScores = components
712
+ .filter((c): c is ComponentScore => c !== undefined)
713
+ .map((c) => c.score);
714
+
715
+ if (validScores.length > 0) {
716
+ // Weighted average (narrative flow and character consistency weighted higher)
717
+ const weights = [1.2, 1.2, 1.0, 1.0, 0.8]; // Matches component order
718
+ const weightedSum = validScores.reduce(
719
+ (sum, s, i) => sum + s * weights[i],
720
+ 0
721
+ );
722
+ const totalWeight = weights
723
+ .slice(0, validScores.length)
724
+ .reduce((a, b) => a + b, 0);
725
+ state.overallScore = weightedSum / totalWeight;
726
+ } else {
727
+ state.overallScore = 50.0;
728
+ }
729
+
730
+ return state;
731
+ }
732
+
733
+ /**
734
+ * Identify narrative gaps from component analyses.
735
+ */
736
+ private identifyGaps(state: CoherenceEngineState): CoherenceEngineState {
737
+ const gaps: Gap[] = [];
738
+
739
+ // Extract issues from each component
740
+ const componentMappings: [keyof CoherenceEngineState, GapType][] = [
741
+ ["narrativeFlowScore", GapType.STRUCTURAL],
742
+ ["characterConsistencyScore", GapType.CHARACTER],
743
+ ["pacingScore", GapType.STRUCTURAL],
744
+ ["themeSaturationScore", GapType.THEMATIC],
745
+ ["continuityScore", GapType.CONTINUITY],
746
+ ];
747
+
748
+ for (const [componentKey, gapType] of componentMappings) {
749
+ const component = state[componentKey] as ComponentScore | undefined;
750
+ if (component?.issues) {
751
+ for (const issue of component.issues) {
752
+ // Determine severity
753
+ let severity: GapSeverity;
754
+ if (component.status === "critical") {
755
+ severity = GapSeverity.CRITICAL;
756
+ } else if (issue.includes("rarely") || issue.includes("disappears")) {
757
+ severity = GapSeverity.MODERATE;
758
+ } else {
759
+ severity = GapSeverity.MINOR;
760
+ }
761
+
762
+ // Determine routing
763
+ let route: RoutingTarget;
764
+ if (gapType === GapType.STRUCTURAL) {
765
+ route = RoutingTarget.STRUCTURIST;
766
+ } else if (gapType === GapType.CHARACTER) {
767
+ route = RoutingTarget.STORYTELLER;
768
+ } else if (gapType === GapType.THEMATIC) {
769
+ route = RoutingTarget.STRUCTURIST;
770
+ } else if (gapType === GapType.SENSORY) {
771
+ route = RoutingTarget.STORYTELLER;
772
+ } else {
773
+ // CONTINUITY
774
+ route = RoutingTarget.AUTHOR;
775
+ }
776
+
777
+ gaps.push(
778
+ createGap(
779
+ this.generateGapId(),
780
+ gapType,
781
+ severity,
782
+ issue,
783
+ route,
784
+ { location: { component: componentKey } }
785
+ )
786
+ );
787
+ }
788
+ }
789
+ }
790
+
791
+ // Sort by severity (critical first)
792
+ const severityOrder: Record<GapSeverity, number> = {
793
+ [GapSeverity.CRITICAL]: 0,
794
+ [GapSeverity.MODERATE]: 1,
795
+ [GapSeverity.MINOR]: 2,
796
+ };
797
+ gaps.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
798
+
799
+ state.gaps = gaps;
800
+
801
+ return state;
802
+ }
803
+
804
+ /**
805
+ * Generate Trinity perspective assessment (Mia/Miette/Ava8).
806
+ *
807
+ * Each persona provides feedback aligned with their perspective:
808
+ * - Mia 🧠: Structural/logical analysis
809
+ * - Miette 🌸: Emotional/resonance analysis
810
+ * - Ava8 🎨: Atmospheric/sensory analysis
811
+ */
812
+ private generateTrinityAssessment(
813
+ state: CoherenceEngineState
814
+ ): CoherenceEngineState {
815
+ const gaps = state.gaps || [];
816
+
817
+ // Component scores
818
+ const flow = state.narrativeFlowScore;
819
+ const character = state.characterConsistencyScore;
820
+ const pacing = state.pacingScore;
821
+ const theme = state.themeSaturationScore;
822
+ const continuity = state.continuityScore;
823
+
824
+ // Build Mia's assessment (structural)
825
+ const miaParts: string[] = [];
826
+ if (flow) {
827
+ miaParts.push(`Structure is ${flow.score.toFixed(0)}% sound.`);
828
+ if (flow.issues.length > 0) {
829
+ miaParts.push(`Key structural gap: ${flow.issues[0]}`);
830
+ }
831
+ }
832
+ if (pacing && pacing.score < 70) {
833
+ miaParts.push(`Pacing needs attention (${pacing.score.toFixed(0)}%).`);
834
+ if (pacing.suggestions.length > 0) {
835
+ miaParts.push(pacing.suggestions[0]);
836
+ }
837
+ }
838
+ if (continuity && continuity.score < 80) {
839
+ miaParts.push(
840
+ `Continuity has ${continuity.issues.length} issues to address.`
841
+ );
842
+ }
843
+
844
+ const mia =
845
+ miaParts.length > 0
846
+ ? miaParts.join(" ")
847
+ : "Structure analysis unavailable.";
848
+
849
+ // Build Miette's assessment (emotional)
850
+ const mietteParts: string[] = [];
851
+ if (character) {
852
+ if (character.score >= 80) {
853
+ mietteParts.push("Character arcs are resonating well.");
854
+ } else {
855
+ mietteParts.push(
856
+ `Character consistency is ${character.score.toFixed(0)}%.`
857
+ );
858
+ if (character.issues.length > 0) {
859
+ mietteParts.push(`The emotional gap: ${character.issues[0]}`);
860
+ }
861
+ }
862
+ }
863
+ if (theme) {
864
+ if (theme.score >= 70) {
865
+ mietteParts.push("Themes are landing with emotional weight.");
866
+ } else {
867
+ mietteParts.push("Themes need stronger emotional anchoring.");
868
+ }
869
+ }
870
+ if (flow?.issues) {
871
+ const jarring = flow.issues.filter((i) => i.includes("Jarring"));
872
+ if (jarring.length > 0) {
873
+ mietteParts.push("Emotional transitions feel abrupt in places.");
874
+ }
875
+ }
876
+
877
+ const miette =
878
+ mietteParts.length > 0
879
+ ? mietteParts.join(" ")
880
+ : "Emotional analysis unavailable.";
881
+
882
+ // Build Ava8's assessment (atmospheric)
883
+ const ava8Parts: string[] = [];
884
+ const sensoryGaps = gaps.filter((g) => g.gapType === GapType.SENSORY);
885
+ if (sensoryGaps.length > 0) {
886
+ ava8Parts.push(`Found ${sensoryGaps.length} sensory gaps to address.`);
887
+ }
888
+
889
+ if (pacing && pacing.score >= 70) {
890
+ ava8Parts.push("Atmospheric rhythm feels balanced.");
891
+ } else {
892
+ ava8Parts.push("Atmosphere could use more grounding moments.");
893
+ }
894
+
895
+ // Check for consecutive high-tension (affects atmosphere)
896
+ if (
897
+ pacing?.issues.some((i) => i.includes("consecutive high-tension"))
898
+ ) {
899
+ ava8Parts.push(
900
+ "The dense tension sections may benefit from visual breathing room."
901
+ );
902
+ }
903
+
904
+ const ava8 =
905
+ ava8Parts.length > 0
906
+ ? ava8Parts.join(" ")
907
+ : "Atmospheric analysis unavailable.";
908
+
909
+ // Determine priorities
910
+ const priorities: string[] = [];
911
+ const criticalGaps = gaps.filter(
912
+ (g) => g.severity === GapSeverity.CRITICAL
913
+ );
914
+ if (criticalGaps.length > 0) {
915
+ priorities.push(...criticalGaps.slice(0, 3).map((g) => g.description));
916
+ } else {
917
+ const moderateGaps = gaps.filter(
918
+ (g) => g.severity === GapSeverity.MODERATE
919
+ );
920
+ if (moderateGaps.length > 0) {
921
+ priorities.push(
922
+ ...moderateGaps.slice(0, 3).map((g) => g.description)
923
+ );
924
+ }
925
+ }
926
+
927
+ if (priorities.length === 0) {
928
+ priorities.push("Minor polish items only - narrative is coherent");
929
+ }
930
+
931
+ state.trinityAssessment = createTrinityAssessment(
932
+ mia,
933
+ miette,
934
+ ava8,
935
+ priorities
936
+ );
937
+
938
+ return state;
939
+ }
940
+
941
+ /**
942
+ * Build the final coherence result object.
943
+ */
944
+ private buildCoherenceResult(
945
+ state: CoherenceEngineState
946
+ ): CoherenceEngineState {
947
+ state.coherenceScore = createCoherenceScore(
948
+ state.overallScore || 50.0,
949
+ state.narrativeFlowScore ||
950
+ createComponentScore(50, "warning"),
951
+ state.characterConsistencyScore ||
952
+ createComponentScore(50, "warning"),
953
+ state.pacingScore || createComponentScore(50, "warning"),
954
+ state.themeSaturationScore ||
955
+ createComponentScore(50, "warning"),
956
+ state.continuityScore ||
957
+ createComponentScore(50, "warning")
958
+ );
959
+
960
+ return state;
961
+ }
962
+
963
+ /**
964
+ * Analyze narrative coherence.
965
+ *
966
+ * @param beats List of story beats to analyze
967
+ * @param characters Optional list of character states
968
+ * @param themes Optional list of thematic threads
969
+ * @param includeMetadata Whether to include full analysis state
970
+ * @returns CoherenceResult with coherenceScore, gaps, and trinityAssessment
971
+ */
972
+ analyze(
973
+ beats: StoryBeat[],
974
+ characters: CharacterState[] = [],
975
+ themes: ThematicThread[] = [],
976
+ includeMetadata: boolean = false
977
+ ): CoherenceResult | CoherenceEngineState {
978
+ // Initialize state
979
+ let state: CoherenceEngineState = {
980
+ beats,
981
+ characters,
982
+ themes,
983
+ };
984
+
985
+ // Run analysis pipeline
986
+ state = this.analyzeNarrativeFlow(state);
987
+ state = this.analyzeCharacterConsistency(state);
988
+ state = this.analyzePacing(state);
989
+ state = this.analyzeThemeSaturation(state);
990
+ state = this.analyzeContinuity(state);
991
+ state = this.calculateOverallScore(state);
992
+ state = this.identifyGaps(state);
993
+ state = this.generateTrinityAssessment(state);
994
+ state = this.buildCoherenceResult(state);
995
+
996
+ if (includeMetadata) {
997
+ return state;
998
+ } else {
999
+ return {
1000
+ coherenceScore: state.coherenceScore!,
1001
+ gaps: state.gaps || [],
1002
+ trinityAssessment: state.trinityAssessment!,
1003
+ };
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Group gaps by their routing target.
1009
+ *
1010
+ * @param gaps List of identified gaps
1011
+ * @returns Dictionary mapping routing target to list of gaps
1012
+ */
1013
+ getRoutingSuggestions(gaps: Gap[]): Record<RoutingTarget, Gap[]> {
1014
+ const routing: Record<RoutingTarget, Gap[]> = {
1015
+ [RoutingTarget.STORYTELLER]: [],
1016
+ [RoutingTarget.STRUCTURIST]: [],
1017
+ [RoutingTarget.ARCHITECT]: [],
1018
+ [RoutingTarget.AUTHOR]: [],
1019
+ };
1020
+
1021
+ for (const gap of gaps) {
1022
+ routing[gap.suggestedRoute].push(gap);
1023
+ }
1024
+
1025
+ return routing;
1026
+ }
1027
+ }