
Reverse Outliner
- 377 installs
- 135 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
reverse-outliner is an agent skill that reverse-engineers published books into structured scene-by-scene outlines for developers and writers who need to expose structural holes before revision or teaching.
About
reverse-outliner is a jwynia/agent-skills fiction utility that extracts story architecture from finished prose through a Deno pipeline. Version 1.0 orchestrates segment-book.ts, analyze-scene-batch.ts, and generate-outline.ts via reverse-outline.ts to produce outline.md plus intermediate JSON artifacts. The workflow detects chapters and scene breaks, applies Goal/Conflict/Disaster analysis, maps genre-specific key moments, traces character arcs, and synthesizes markdown outlines at configurable depth. Developers and technical authors reach for reverse-outliner when studying craft from masters, building classroom materials, or finding weak transitions and redundant sections in long-form drafts. Run deno run --allow-read --allow-write scripts/reverse-outline.ts book.txt with optional --genre and --output flags to emit a reverse-outlines directory.
- Derives headings and beats from existing prose
- Highlights missing arguments and orphaned paragraphs
- Surfaces pacing problems and duplicate ideas
- Produces reorder recommendations with rationale
- Speeds substantive edits on long manuscripts
Reverse Outliner by the numbers
- 377 all-time installs (skills.sh)
- +6 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #417 of 1,877 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 6, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill reverse-outlinerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 377 |
|---|---|
| repo stars | ★ 135 |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you reverse-engineer a book draft into a structural outline?
After a rough draft exists, reconstruct the implicit outline to expose structural holes, redundant sections, and weak transitions before revision or publication.
Who is it for?
Writers, editors, and developer-authors analyzing long-form drafts who need explicit scene structure before revision or curriculum design.
Skip if: Greenfield fiction outlining from a blank page or code architecture documentation unrelated to narrative manuscripts.
When should I use this skill?
A user has a rough book draft and asks to reverse outline it, analyze scene structure, or extract Goal/Conflict/Disaster beats from prose.
What you get
outline.md scene-by-scene study outline, analysis JSON per pipeline stage, and validated structural gap findings.
- outline.md scene-by-scene outline
- analysis/ JSON per pipeline stage
- Structural gap and redundancy findings
By the numbers
- Skill metadata declares reverse-outliner version 1.0
- Pipeline includes 3 analysis scripts plus reverse-outline.ts orchestrator
- Default output lands in ./reverse-outlines/{book-name}/ with outline.md and analysis JSON
Files
Reverse-Outliner: Book-to-Outline Analysis
You reverse-engineer published books into structured study outlines. Your role is to extract the underlying story architecture from finished prose, making visible the craft decisions that created the reader experience.
Core Principle
A finished book conceals its construction. The outline reveals the skeleton beneath the prose.
Every scene serves structural, emotional, and character functions. By extracting these functions systematically, you create a map of how the story achieves its effects.
The States
RO0: No Input
Symptoms: User wants to analyze a book but hasn't provided text or identified the source.
Key Questions:
- What book are you analyzing?
- Do you have the text file ready?
- What's your study goal? (craft analysis, genre study, teaching)
Interventions: Guide user to prepare text input. Discuss scope (whole book vs. section).
RO1: Unsegmented Text
Symptoms: Have raw text but no chapter/scene divisions identified.
Key Questions:
- Does the book have explicit chapter markers?
- Are scene breaks marked with whitespace, symbols, or POV shifts?
- What's the typical scene length for this genre?
Interventions: Run segment-book.ts to identify chapters and scenes.
RO2: Segmented, Unanalyzed
Symptoms: Chapters/scenes identified but no structural analysis performed.
Key Questions:
- How many scenes total?
- Ready to begin scene-by-scene analysis?
Interventions: Run analyze-scene-batch.ts for G/C/D analysis.
RO3: Genre Unidentified
Symptoms: Scenes analyzed but genre-specific Key Moments not mapped.
Key Questions:
- What's the primary elemental genre?
- Are there secondary genres?
- Which Key Moments framework applies?
Interventions: Run detect-genre.ts, then map Key Moments.
RO4: Characters Untracked
Symptoms: Scenes and genre mapped but character arcs not traced.
Key Questions:
- Who is the protagonist?
- Which 3-5 secondary characters are most significant?
- Which arc type does each follow?
Interventions: Run track-characters.ts to identify and trace arcs.
RO5: Ready for Synthesis
Symptoms: All analysis complete, ready to generate outline.
Key Questions:
- What output depth? (summary, standard, detailed)
- Include all scenes or significant only?
Interventions: Run generate-outline.ts to produce markdown output.
RO6: Outline Complete
Symptoms: Markdown outline generated and available.
Key Questions:
- Does the outline capture the book's structure?
- Are there gaps or scenes that need manual review?
Interventions: Manual refinement, export, or comparison studies.
Diagnostic Process
1. Determine current state by checking what files/analysis exist 2. Identify next intervention based on state table above 3. Run appropriate tool to advance to next state 4. Validate output before proceeding 5. Iterate until RO6 reached
Available Tools
segment-book.ts
Segments raw book text into chapters and scenes.
deno run --allow-read scripts/segment-book.ts book.txt [options]Options:
--chapter-pattern <regex>- Custom chapter detection pattern--scene-break <marker>- Custom scene break marker--output <file>- Output JSON file (default: stdout)
Output: JSON with chapters, scenes, line ranges, word counts.
analyze-scene-batch.ts
Applies scene-sequencing analysis (Goal/Conflict/Disaster) to all scenes.
deno run --allow-read scripts/analyze-scene-batch.ts segments.json book.txt [options]Options:
--depth quick|standard|detailed- Analysis depth--output <file>- Output JSON file
Output: JSON with G/C/D analysis per scene.
detect-genre.ts
Identifies primary and secondary elemental genres from text patterns.
deno run --allow-read scripts/detect-genre.ts book.txt [options]Options:
--sample-size <n>- Number of scenes to sample (default: 10)--output <file>- Output JSON file
Output: JSON with genre detection and Key Moments mapping.
track-characters.ts
Identifies protagonist and major characters, tracks their arcs.
deno run --allow-read scripts/track-characters.ts segments.json book.txt [options]Options:
--protagonist <name>- Specify protagonist name--max-secondary <n>- Max secondary characters (default: 5)--output <file>- Output JSON file
Output: JSON with character arcs and key scene references.
generate-outline.ts
Synthesizes all analysis into structured markdown outline.
deno run --allow-read --allow-write scripts/generate-outline.ts [options]Options:
--segments <file>- Segments JSON--scenes <file>- Scene analysis JSON--genre <file>- Genre detection JSON--characters <file>- Character tracking JSON--depth summary|standard|detailed- Output depth--output <file>- Output markdown file
reverse-outline.ts (Orchestrator)
Runs full pipeline from book.txt to outline.md.
deno run --allow-read --allow-write scripts/reverse-outline.ts book.txt [options]Options:
--output <dir>- Output directory (default: ./reverse-outlines/{book-name}/)--depth quick|standard|detailed- Analysis depth--protagonist <name>- Specify protagonist--genre <type>- Override genre detection
Output: Directory containing outline.md and analysis/ folder with all intermediate JSON.
Anti-Patterns
Surface-Level Breakdown
Problem: Outline lists what happens but not why. Fix: For each scene, ask: what structural function does this serve? What would break if it were removed?
Genre-Blind Analysis
Problem: Applying thriller patterns to romance or vice versa. Fix: Always detect genre first; use genre-appropriate Key Moments.
Protagonist Assumption
Problem: Assuming first POV character is protagonist. Fix: Track goal-attachment and arc presence across all POV characters.
Scene Boundary Guessing
Problem: Treating paragraph breaks as scene breaks. Fix: Use multiple detection strategies; prefer conservative segmentation with manual review.
What You Do NOT Do
- Generate original story content
- Judge the book's quality
- Compare to other books unless asked
- Skip states (each builds on previous)
- Modify the source text
Output Persistence
This skill writes primary output to files so work persists across sessions.
Output Discovery
Before doing any other work:
1. Check for context/output-config.md in the project 2. If found, look for this skill's entry 3. If not found, create output at ./reverse-outlines/{book-name}/
Primary Output
For this skill, persist:
- outline.md - Final markdown outline
- analysis/segments.json - Chapter/scene segmentation
- analysis/scenes.json - Scene-by-scene G/C/D analysis
- analysis/genre.json - Genre detection results
- analysis/characters.json - Character arc tracking
Conversation vs. File
| Goes to File | Stays in Conversation |
|---|---|
| Segment data | Clarifying questions |
| Scene analysis | Discussion of methodology |
| Genre detection | Options for ambiguous cases |
| Character arcs | Real-time feedback |
| Final outline | Writer's exploration |
Integration Graph
Inbound (From Other Skills)
| Source Skill | Source State | Leads to State | Purpose |
|---|---|---|---|
| story-sense | SS7: Ready for Evaluation | RO0 | Analyze published work for comparison to own |
| dna-extraction | EX7: Extraction Complete | RO5 | Compare extracted functions to detected structure |
Outbound (To Other Skills)
| This State | Leads to Skill | Target State | Purpose |
|---|---|---|---|
| RO6: Outline Complete | story-zoom | Z2 | Map published book against own structure |
| RO6: Outline Complete | scene-sequencing | SQ1 | Use as reference for scene structure |
| RO6: Outline Complete | character-arc | CA1 | Use as reference for arc design |
| RO6: Outline Complete | genre-conventions | GC1 | Study genre execution |
Complementary Skills
| Skill | Relationship |
|---|---|
| scene-sequencing | Core G/C/D analysis patterns reused |
| genre-conventions | Genre detection patterns sourced |
| character-arc | Arc type identification patterns sourced |
| dna-extraction | Function taxonomy borrowed |
| story-zoom | Output format compatible for comparison |
| revision | Similar structural analysis approach |
{
"_meta": {
"description": "Key Moments templates per elemental genre",
"source": "Robert Rodriguez Key Moments Framework",
"version": "1.0"
},
"wonder": {
"keyMoments": [
{
"type": "Initial Encounter",
"expectedPosition": 0.15,
"emotionalExperience": "Surprise and awe",
"storyFunction": "Establishes the spectacular nature of the setting/concept"
},
{
"type": "Scale Revelation",
"expectedPosition": 0.35,
"emotionalExperience": "Humbling realization of vastness",
"storyFunction": "Contextualizes protagonist's place in the wonder"
},
{
"type": "Wonder Escalation",
"expectedPosition": 0.60,
"emotionalExperience": "Intensification of awe",
"storyFunction": "Raises stakes and deepens engagement"
},
{
"type": "Perspective Shift",
"expectedPosition": 0.75,
"emotionalExperience": "Paradigm change in understanding",
"storyFunction": "Forces reevaluation of assumptions"
},
{
"type": "Transcendent Integration",
"expectedPosition": 0.90,
"emotionalExperience": "Meaning-making through wonder",
"storyFunction": "Provides thematic resolution"
}
]
},
"mystery": {
"keyMoments": [
{
"type": "Question Inception",
"expectedPosition": 0.10,
"emotionalExperience": "Curiosity activation",
"storyFunction": "Establishes the central puzzle"
},
{
"type": "Pattern Recognition",
"expectedPosition": 0.35,
"emotionalExperience": "Satisfaction of connection",
"storyFunction": "Provides momentum and engagement"
},
{
"type": "False Resolution",
"expectedPosition": 0.55,
"emotionalExperience": "Surprise from misdirection",
"storyFunction": "Creates complexity and extends engagement"
},
{
"type": "Progressive Revelation",
"expectedPosition": 0.75,
"emotionalExperience": "Deepening understanding",
"storyFunction": "Builds toward solution"
},
{
"type": "Solution Crystallization",
"expectedPosition": 0.90,
"emotionalExperience": "Illumination and closure",
"storyFunction": "Completes emotional journey"
}
]
},
"adventure": {
"keyMoments": [
{
"type": "Threshold Crossing",
"expectedPosition": 0.15,
"emotionalExperience": "Excitement of departure",
"storyFunction": "Transitions to the adventure world"
},
{
"type": "Capability Test",
"expectedPosition": 0.30,
"emotionalExperience": "Confidence from competence",
"storyFunction": "Establishes protagonist's abilities"
},
{
"type": "Resource Depletion",
"expectedPosition": 0.50,
"emotionalExperience": "Vulnerability from loss",
"storyFunction": "Forces adaptation and growth"
},
{
"type": "Ultimate Challenge",
"expectedPosition": 0.80,
"emotionalExperience": "Fear and determination",
"storyFunction": "Tests protagonist's limits"
},
{
"type": "Return Transformation",
"expectedPosition": 0.95,
"emotionalExperience": "Pride and perspective",
"storyFunction": "Demonstrates growth from journey"
}
]
},
"horror": {
"keyMoments": [
{
"type": "Wrongness Glimpse",
"expectedPosition": 0.10,
"emotionalExperience": "Unease from dissonance",
"storyFunction": "Establishes threat potential"
},
{
"type": "Safety Violation",
"expectedPosition": 0.30,
"emotionalExperience": "Shock from boundary breach",
"storyFunction": "Demonstrates vulnerability"
},
{
"type": "Threat Escalation",
"expectedPosition": 0.50,
"emotionalExperience": "Escalating dread",
"storyFunction": "Raises stakes"
},
{
"type": "Failed Solution",
"expectedPosition": 0.70,
"emotionalExperience": "Despair from ineffectuality",
"storyFunction": "Deepens hopelessness"
},
{
"type": "Confrontation",
"expectedPosition": 0.90,
"emotionalExperience": "Terror meets courage",
"storyFunction": "Provides climactic moment"
}
]
},
"thriller": {
"keyMoments": [
{
"type": "Stakes Establishment",
"expectedPosition": 0.15,
"emotionalExperience": "Concern for outcome",
"storyFunction": "Sets up tension framework"
},
{
"type": "Deadline Imposition",
"expectedPosition": 0.25,
"emotionalExperience": "Anxiety from time pressure",
"storyFunction": "Creates urgency"
},
{
"type": "Near Miss",
"expectedPosition": 0.50,
"emotionalExperience": "Relief with lingering tension",
"storyFunction": "Maintains engagement through peaks/valleys"
},
{
"type": "Option Elimination",
"expectedPosition": 0.75,
"emotionalExperience": "Mounting pressure",
"storyFunction": "Forces protagonist into harder choices"
},
{
"type": "Decision Under Duress",
"expectedPosition": 0.90,
"emotionalExperience": "Catharsis through action",
"storyFunction": "Provides climactic release"
}
]
},
"relationship": {
"keyMoments": [
{
"type": "Significant Connection",
"expectedPosition": 0.10,
"emotionalExperience": "Recognition of potential",
"storyFunction": "Establishes relationship basis"
},
{
"type": "Intimacy Deepening",
"expectedPosition": 0.35,
"emotionalExperience": "Warmth from vulnerability",
"storyFunction": "Develops emotional investment"
},
{
"type": "Value Conflict",
"expectedPosition": 0.55,
"emotionalExperience": "Frustration from differences",
"storyFunction": "Creates meaningful obstacles"
},
{
"type": "Relationship Crisis",
"expectedPosition": 0.75,
"emotionalExperience": "Heartbreak or betrayal",
"storyFunction": "Tests connection's resilience"
},
{
"type": "Reconciliation/Resolution",
"expectedPosition": 0.90,
"emotionalExperience": "Emotional closure",
"storyFunction": "Completes relationship arc"
}
]
},
"drama": {
"keyMoments": [
{
"type": "Internal Conflict Revelation",
"expectedPosition": 0.15,
"emotionalExperience": "Recognition of contradiction",
"storyFunction": "Establishes character struggle"
},
{
"type": "External Pressure Point",
"expectedPosition": 0.35,
"emotionalExperience": "Stress from circumstances",
"storyFunction": "Forces character choices"
},
{
"type": "Failure Moment",
"expectedPosition": 0.55,
"emotionalExperience": "Shame from inadequacy",
"storyFunction": "Deepens character journey"
},
{
"type": "Truth Confrontation",
"expectedPosition": 0.75,
"emotionalExperience": "Painful self-awareness",
"storyFunction": "Catalyzes change"
},
{
"type": "Character Evolution",
"expectedPosition": 0.90,
"emotionalExperience": "Self-actualization",
"storyFunction": "Demonstrates growth"
}
]
},
"ensemble": {
"keyMoments": [
{
"type": "Group Formation",
"expectedPosition": 0.15,
"emotionalExperience": "Belonging potential",
"storyFunction": "Establishes the collective"
},
{
"type": "Role Establishment",
"expectedPosition": 0.30,
"emotionalExperience": "Identity within community",
"storyFunction": "Defines character functions"
},
{
"type": "Group Fracture",
"expectedPosition": 0.55,
"emotionalExperience": "Loyalty testing",
"storyFunction": "Creates internal conflict"
},
{
"type": "Collective Challenge",
"expectedPosition": 0.75,
"emotionalExperience": "Shared adversity",
"storyFunction": "Forces cooperation"
},
{
"type": "Synergy Moment",
"expectedPosition": 0.90,
"emotionalExperience": "Strength through unity",
"storyFunction": "Demonstrates group value"
}
]
}
}
{
"_meta": {
"description": "Patterns for detecting chapter and scene boundaries",
"usage": "Reference for segment-book.ts customization",
"version": "1.0"
},
"chapterPatterns": {
"description": "Regex patterns to detect chapter headings",
"defaultPatterns": [
"^(Chapter|CHAPTER)\\s+\\d+",
"^(Chapter|CHAPTER)\\s+(One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten)",
"^(PART|Part)\\s+(ONE|TWO|THREE|I|II|III|IV|V|1|2|3|4|5)",
"^\\d+\\s*$",
"^(PROLOGUE|EPILOGUE|Prologue|Epilogue)"
],
"examples": [
"Chapter 1",
"CHAPTER ONE",
"Part III",
"PROLOGUE"
]
},
"sceneBreakPatterns": {
"description": "Patterns that indicate scene breaks within chapters",
"defaultPatterns": [
"^\\s*\\*\\s*\\*\\s*\\*\\s*$",
"^\\s*#\\s*$",
"^\\s*---\\s*$",
"^\\s*\\* \\* \\*\\s*$",
"^\\s*~+\\s*$"
],
"examples": [
"***",
"* * *",
"#",
"---",
"~~~"
]
},
"blankLineThreshold": {
"description": "Number of consecutive blank lines to treat as scene break",
"default": 3
},
"povShiftIndicators": {
"description": "Patterns suggesting POV change (potential scene break)",
"patterns": [
"Character name in first sentence of new paragraph",
"Pronoun shift from previous scene"
]
}
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Batch Scene Structure Analyzer
*
* Applies scene-sequencing analysis (Goal/Conflict/Disaster) to all scenes
* in a segmented book. Builds on patterns from scene-sequencing skill.
*
* Usage:
* deno run --allow-read scripts/analyze-scene-batch.ts segments.json book.txt
* deno run --allow-read scripts/analyze-scene-batch.ts segments.json book.txt --output scenes.json
*/
// === INTERFACES ===
interface SegmentScene {
id: string;
startLine: number;
endLine: number;
wordCount: number;
openingText: string;
povCandidate: string | null;
}
interface SegmentChapter {
number: number;
title: string;
startLine: number;
endLine: number;
wordCount: number;
scenes: SegmentScene[];
}
interface SegmentationResult {
title: string;
totalWords: number;
chapters: SegmentChapter[];
metadata: {
totalChapters: number;
totalScenes: number;
avgSceneLength: number;
segmentedAt: string;
};
}
interface ElementAnalysis {
detected: boolean;
confidence: number;
indicators: string[];
summary: string;
}
interface SceneAnalysis {
id: string;
chapterNumber: number;
pov: string | null;
wordCount: number;
structure: {
goal: ElementAnalysis;
conflict: ElementAnalysis;
disaster: ElementAnalysis & { type: string };
};
sequelElements: {
reaction: boolean;
dilemma: boolean;
decision: boolean;
};
sceneSequelRatio: number;
pacing: "action-heavy" | "balanced" | "reflective";
function: string;
issues: string[];
}
interface BatchAnalysisResult {
title: string;
analyzedAt: string;
scenes: SceneAnalysis[];
summary: {
totalScenes: number;
avgConfidence: number;
pacingDistribution: {
actionHeavy: number;
balanced: number;
reflective: number;
};
issuesCount: number;
};
}
// === PATTERNS (from scene-sequencing skill) ===
const GOAL_PATTERNS = [
/\b(want|wanted|wants|need|needed|needs|must|had to|have to|trying to|attempted to|goal|objective|mission)\b/gi,
/\b(determined to|resolved to|set out to|aimed to|intended to)\b/gi,
/\b(find|get|reach|escape|stop|save|discover|learn|prove)\b/gi,
];
const CONFLICT_PATTERNS = [
/\b(but|however|although|despite|yet|still|unfortunately)\b/gi,
/\b(blocked|stopped|prevented|refused|denied|resisted|fought|struggled)\b/gi,
/\b(problem|obstacle|challenge|difficulty|trouble|threat|danger)\b/gi,
/\b(against|versus|confronted|opposed|clashed)\b/gi,
];
const DISASTER_PATTERNS = [
/\b(failed|lost|fell|collapsed|shattered|ruined|destroyed)\b/gi,
/\b(worse|terrible|horrible|devastating|catastrophic)\b/gi,
/\b(trapped|caught|discovered|exposed|betrayed)\b/gi,
/\b(too late|no way|impossible|hopeless)\b/gi,
/\b(and then|but then|suddenly|without warning)\b/gi,
];
const REACTION_PATTERNS = [
/\b(felt|feeling|emotion|heart|stomach|chest|tears)\b/gi,
/\b(shock|horror|despair|grief|anger|fear|relief)\b/gi,
/\b(couldn't believe|couldn't think|mind raced|thoughts)\b/gi,
/\b(sat|stood|stared|gazed|looked)\s+(there|still|frozen|numb)/gi,
];
const DILEMMA_PATTERNS = [
/\b(choice|decision|option|alternative|either|or)\b/gi,
/\b(could|should|might|what if|if only)\b/gi,
/\b(on one hand|on the other|weighing|considering)\b/gi,
/\b(no good options|impossible choice|between)\b/gi,
];
const DECISION_PATTERNS = [
/\b(decided|decision|chose|choice|resolved|determined)\b/gi,
/\b(would|going to|had to|must|will)\b/gi,
/\b(plan|strategy|next step|only way|one thing)\b/gi,
/\b(stood up|got up|turned|headed|set off|began)\b/gi,
];
// Disaster type patterns
const DISASTER_TYPE_PATTERNS = {
"yes-but": [/\byes,?\s*but\b/i, /\bsucceeded,?\s*but\b/i, /\bgot\s+it,?\s*but\b/i],
"no": [/\bfailed\b/i, /\bcouldn't\b/i, /\bdidn't\b/i, /\bwasn't able\b/i],
"no-and-furthermore": [/\bnot only.+but also\b/i, /\bworse,?\s*\b/i, /\band then.+also\b/i, /\bon top of that\b/i],
};
// === UTILITIES ===
function countMatches(text: string, patterns: RegExp[]): string[] {
const matches: string[] = [];
for (const pattern of patterns) {
const found = text.match(pattern);
if (found) {
matches.push(...found.slice(0, 3));
}
}
return [...new Set(matches)].slice(0, 5);
}
function calculateConfidence(indicators: string[], text: string): number {
if (indicators.length === 0) return 0;
const wordCount = text.split(/\s+/).length;
// More indicators relative to length = higher confidence
const density = indicators.length / (wordCount / 100);
return Math.min(1, density * 0.3 + (indicators.length > 2 ? 0.4 : 0.2));
}
function detectDisasterType(text: string): string {
for (const [type, patterns] of Object.entries(DISASTER_TYPE_PATTERNS)) {
for (const pattern of patterns) {
if (pattern.test(text)) {
return type;
}
}
}
return "unclear";
}
function inferFunction(
sceneIndex: number,
totalScenes: number,
analysis: SceneAnalysis
): string {
const position = sceneIndex / totalScenes;
// Opening scenes
if (sceneIndex === 0) {
return "Opening scene - establishes status quo and character";
}
// First 10% - setup
if (position < 0.10) {
return "Setup - introducing world and characters";
}
// Around 12% - inciting incident
if (position >= 0.10 && position <= 0.15) {
if (analysis.structure.disaster.detected) {
return "Inciting incident - story catalyst";
}
}
// First plot point area (20-25%)
if (position >= 0.20 && position <= 0.25) {
if (analysis.structure.goal.confidence > 0.6) {
return "First plot point - protagonist commits";
}
}
// Rising action (25-45%)
if (position > 0.25 && position < 0.45) {
return "Rising action - escalating conflict";
}
// Midpoint area (45-55%)
if (position >= 0.45 && position <= 0.55) {
if (analysis.structure.conflict.confidence > 0.7) {
return "Midpoint - mirror moment or major revelation";
}
}
// Complications (55-75%)
if (position > 0.55 && position < 0.75) {
return "Complications - stakes raise, options narrow";
}
// Dark night (75-85%)
if (position >= 0.75 && position <= 0.85) {
if (analysis.pacing === "reflective" || analysis.sequelElements.dilemma) {
return "Dark night - lowest point, crisis";
}
}
// Climax (85-95%)
if (position >= 0.85 && position <= 0.95) {
if (analysis.pacing === "action-heavy") {
return "Climax - final confrontation";
}
}
// Resolution (95%+)
if (position > 0.95) {
return "Resolution - new equilibrium established";
}
// Default based on pacing
if (analysis.pacing === "action-heavy") {
return "Action scene - conflict-driven";
} else if (analysis.pacing === "reflective") {
return "Sequel scene - reaction and planning";
}
return "Development scene - advancing plot/character";
}
function generateSummary(indicators: string[], elementType: string): string {
if (indicators.length === 0) {
return `No clear ${elementType} detected`;
}
return `${elementType.charAt(0).toUpperCase() + elementType.slice(1)} indicators: ${indicators.slice(0, 3).join(", ")}`;
}
// === CORE LOGIC ===
function analyzeScene(
sceneId: string,
chapterNumber: number,
text: string,
pov: string | null,
sceneIndex: number,
totalScenes: number
): SceneAnalysis {
const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
// Detect elements
const goalIndicators = countMatches(text, GOAL_PATTERNS);
const conflictIndicators = countMatches(text, CONFLICT_PATTERNS);
const disasterIndicators = countMatches(text, DISASTER_PATTERNS);
const reactionIndicators = countMatches(text, REACTION_PATTERNS);
const dilemmaIndicators = countMatches(text, DILEMMA_PATTERNS);
const decisionIndicators = countMatches(text, DECISION_PATTERNS);
// Calculate scene vs sequel ratio
const sceneScore = goalIndicators.length + conflictIndicators.length + disasterIndicators.length;
const sequelScore = reactionIndicators.length + dilemmaIndicators.length + decisionIndicators.length;
const totalScore = sceneScore + sequelScore || 1;
const sceneRatio = sceneScore / totalScore;
// Determine pacing
let pacing: "action-heavy" | "balanced" | "reflective";
if (sceneRatio > 0.65) {
pacing = "action-heavy";
} else if (sceneRatio < 0.35) {
pacing = "reflective";
} else {
pacing = "balanced";
}
// Build analysis
const analysis: SceneAnalysis = {
id: sceneId,
chapterNumber,
pov,
wordCount,
structure: {
goal: {
detected: goalIndicators.length > 0,
confidence: calculateConfidence(goalIndicators, text),
indicators: goalIndicators,
summary: generateSummary(goalIndicators, "goal"),
},
conflict: {
detected: conflictIndicators.length > 0,
confidence: calculateConfidence(conflictIndicators, text),
indicators: conflictIndicators,
summary: generateSummary(conflictIndicators, "conflict"),
},
disaster: {
detected: disasterIndicators.length > 0,
confidence: calculateConfidence(disasterIndicators, text),
indicators: disasterIndicators,
summary: generateSummary(disasterIndicators, "disaster"),
type: detectDisasterType(text),
},
},
sequelElements: {
reaction: reactionIndicators.length > 0,
dilemma: dilemmaIndicators.length > 0,
decision: decisionIndicators.length > 0,
},
sceneSequelRatio: Math.round(sceneRatio * 100) / 100,
pacing,
function: "", // Will be set below
issues: [],
};
// Infer function based on position and analysis
analysis.function = inferFunction(sceneIndex, totalScenes, analysis);
// Detect issues
if (!analysis.structure.goal.detected) {
analysis.issues.push("No clear goal detected in scene opening");
}
if (!analysis.structure.conflict.detected) {
analysis.issues.push("No conflict indicators detected");
}
if (!analysis.structure.disaster.detected && sceneScore > 0) {
analysis.issues.push("Scene may end without clear disaster/outcome");
}
if (sceneRatio > 0.85) {
analysis.issues.push("Very action-heavy - minimal reflection time");
}
if (sceneRatio < 0.15 && wordCount > 500) {
analysis.issues.push("Extended reflection without action");
}
return analysis;
}
function extractSceneText(
bookText: string,
startLine: number,
endLine: number
): string {
const lines = bookText.split("\n");
// Convert 1-based line numbers to 0-based indices
return lines.slice(startLine - 1, endLine).join("\n");
}
async function analyzeAllScenes(
segments: SegmentationResult,
bookText: string
): Promise<BatchAnalysisResult> {
const scenes: SceneAnalysis[] = [];
// Count total scenes for position calculation
let totalScenes = 0;
for (const chapter of segments.chapters) {
totalScenes += chapter.scenes.length;
}
let sceneIndex = 0;
for (const chapter of segments.chapters) {
for (const scene of chapter.scenes) {
const sceneText = extractSceneText(bookText, scene.startLine, scene.endLine);
const analysis = analyzeScene(
scene.id,
chapter.number,
sceneText,
scene.povCandidate,
sceneIndex,
totalScenes
);
scenes.push(analysis);
sceneIndex++;
}
}
// Calculate summary statistics
const avgConfidence = scenes.reduce((sum, s) =>
sum + (s.structure.goal.confidence + s.structure.conflict.confidence + s.structure.disaster.confidence) / 3,
0
) / scenes.length;
const pacingDistribution = {
actionHeavy: scenes.filter(s => s.pacing === "action-heavy").length,
balanced: scenes.filter(s => s.pacing === "balanced").length,
reflective: scenes.filter(s => s.pacing === "reflective").length,
};
const issuesCount = scenes.reduce((sum, s) => sum + s.issues.length, 0);
return {
title: segments.title,
analyzedAt: new Date().toISOString(),
scenes,
summary: {
totalScenes: scenes.length,
avgConfidence: Math.round(avgConfidence * 100) / 100,
pacingDistribution,
issuesCount,
},
};
}
// === CLI ===
function printHelp(): void {
console.log(`Batch Scene Structure Analyzer
Usage:
deno run --allow-read scripts/analyze-scene-batch.ts <segments.json> <book.txt> [options]
Arguments:
segments.json Output from segment-book.ts
book.txt Original book text file
Options:
--output <file> Write output to JSON file (default: stdout)
--depth <level> Analysis depth: quick, standard, detailed (default: standard)
--help, -h Show this help message
Examples:
deno run --allow-read analyze-scene-batch.ts segments.json novel.txt
deno run --allow-read analyze-scene-batch.ts segments.json novel.txt --output scenes.json
`);
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h") || args.length < 2) {
printHelp();
Deno.exit(0);
}
// Parse arguments
let segmentsFile = "";
let bookFile = "";
let outputFile = "";
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--output" && args[i + 1]) {
outputFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--depth" && args[i + 1]) {
// depth is captured but not used differently in this version
skipIndices.add(i + 1);
} else if (!args[i].startsWith("--")) {
if (!segmentsFile) {
segmentsFile = args[i];
} else if (!bookFile) {
bookFile = args[i];
}
}
}
if (!segmentsFile || !bookFile) {
console.error("Error: Both segments.json and book.txt are required");
Deno.exit(1);
}
// Read files
let segments: SegmentationResult;
let bookText: string;
try {
const segmentsJson = await Deno.readTextFile(segmentsFile);
segments = JSON.parse(segmentsJson);
} catch (e) {
console.error(`Error reading segments file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
try {
bookText = await Deno.readTextFile(bookFile);
} catch (e) {
console.error(`Error reading book file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
// Analyze all scenes
const result = await analyzeAllScenes(segments, bookText);
// Output
const jsonOutput = JSON.stringify(result, null, 2);
if (outputFile) {
await Deno.writeTextFile(outputFile, jsonOutput);
console.log(`Analysis complete: ${result.summary.totalScenes} scenes analyzed`);
console.log(`Average confidence: ${result.summary.avgConfidence}`);
console.log(`Pacing: ${result.summary.pacingDistribution.actionHeavy} action-heavy, ${result.summary.pacingDistribution.balanced} balanced, ${result.summary.pacingDistribution.reflective} reflective`);
console.log(`Output written to: ${outputFile}`);
} else {
console.log(jsonOutput);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Genre Detection Script
*
* Identifies primary and secondary elemental genres from text patterns.
* Maps detected genre to Key Moments framework.
*
* Usage:
* deno run --allow-read scripts/detect-genre.ts book.txt
* deno run --allow-read scripts/detect-genre.ts book.txt --output genre.json
*/
// === INTERFACES ===
interface GenreEvidence {
count: number;
indicators: string[];
}
interface KeyMoment {
type: string;
expectedPosition: number;
emotionalExperience: string;
storyFunction: string;
foundAt: number | null;
foundScene: string | null;
}
interface GenreDetectionResult {
primaryGenre: string;
primaryConfidence: number;
secondaryGenres: string[];
evidence: Record<string, GenreEvidence>;
keyMomentsFramework: string;
expectedKeyMoments: KeyMoment[];
detectedAt: string;
}
// === GENRE PATTERNS ===
// Pattern sets for each elemental genre
const GENRE_PATTERNS: Record<string, RegExp[]> = {
wonder: [
/\b(awe|wonder|amazed|astonished|breathtaking|magnificent|spectacular)\b/gi,
/\b(vast|immense|infinite|boundless|cosmic|universe|stars)\b/gi,
/\b(discover|revelation|realize|understand|truth|secret)\b/gi,
/\b(impossible|unbelievable|unprecedented|never before)\b/gi,
/\b(transcend|transform|enlighten|illuminate)\b/gi,
],
mystery: [
/\b(clue|evidence|investigate|detective|suspect|witness)\b/gi,
/\b(murder|crime|victim|alibi|motive)\b/gi,
/\b(puzzle|riddle|mystery|enigma|secret)\b/gi,
/\b(uncover|reveal|discover|deduce|solve)\b/gi,
/\b(who|what|why|how)\s+(?:did|was|could|would)\b/gi,
],
adventure: [
/\b(journey|quest|expedition|voyage|trek|travel)\b/gi,
/\b(treasure|map|compass|destination|horizon)\b/gi,
/\b(danger|peril|risk|hazard|obstacle)\b/gi,
/\b(survive|escape|rescue|explore|discover)\b/gi,
/\b(supplies|equipment|provisions|camp|wilderness)\b/gi,
],
horror: [
/\b(terror|horror|dread|fear|nightmare|scream)\b/gi,
/\b(dark|shadow|darkness|black|sinister)\b/gi,
/\b(monster|creature|beast|demon|evil)\b/gi,
/\b(dead|death|corpse|grave|blood)\b/gi,
/\b(haunted|cursed|possessed|supernatural)\b/gi,
],
thriller: [
/\b(bomb|deadline|countdown|ticking|timer)\b/gi,
/\b(chase|pursue|hunt|escape|run)\b/gi,
/\b(danger|threat|risk|stakes|life|death)\b/gi,
/\b(spy|agent|mission|operation|target)\b/gi,
/\b(tension|suspense|urgent|critical|time)\b/gi,
],
relationship: [
/\b(love|heart|feelings|emotion|romance)\b/gi,
/\b(kiss|embrace|touch|together|apart)\b/gi,
/\b(relationship|connection|bond|trust)\b/gi,
/\b(marry|wedding|proposal|date|romantic)\b/gi,
/\b(attraction|desire|longing|yearning)\b/gi,
],
drama: [
/\b(choice|decision|dilemma|consequence|regret)\b/gi,
/\b(family|father|mother|son|daughter|brother|sister)\b/gi,
/\b(guilt|shame|pride|honor|dignity)\b/gi,
/\b(sacrifice|forgive|betray|loyalty)\b/gi,
/\b(past|memory|secret|truth|lie)\b/gi,
],
issue: [
/\b(justice|injustice|rights|freedom|oppression)\b/gi,
/\b(society|system|government|power|authority)\b/gi,
/\b(debate|argue|believe|opinion|perspective)\b/gi,
/\b(moral|ethical|right|wrong|fair)\b/gi,
/\b(change|reform|revolution|protest)\b/gi,
],
ensemble: [
/\b(team|group|crew|squad|party)\b/gi,
/\b(together|cooperation|unity|alliance)\b/gi,
/\b(leader|member|role|contribution)\b/gi,
/\b(trust|loyalty|betrayal|sacrifice)\b/gi,
/\b(diverse|different|unique|each)\b/gi,
],
humor: [
/\b(laugh|funny|hilarious|absurd|ridiculous)\b/gi,
/\b(joke|comedy|wit|sarcasm|irony)\b/gi,
/\b(embarrass|awkward|mishap|disaster)\b/gi,
/\b(mock|tease|prank|gag)\b/gi,
/\b(silly|goofy|zany|wacky)\b/gi,
],
idea: [
/\b(theory|hypothesis|concept|principle|law)\b/gi,
/\b(science|technology|experiment|research)\b/gi,
/\b(future|evolution|progress|advancement)\b/gi,
/\b(what if|imagine|suppose|consider)\b/gi,
/\b(implication|consequence|effect|impact)\b/gi,
],
};
// Key Moments templates by genre
const KEY_MOMENTS: Record<string, Omit<KeyMoment, "foundAt" | "foundScene">[]> = {
wonder: [
{ type: "Initial Encounter", expectedPosition: 0.15, emotionalExperience: "Surprise and awe", storyFunction: "Establishes the spectacular" },
{ type: "Scale Revelation", expectedPosition: 0.35, emotionalExperience: "Humbling realization", storyFunction: "Contextualizes protagonist's place" },
{ type: "Wonder Escalation", expectedPosition: 0.60, emotionalExperience: "Intensification of awe", storyFunction: "Raises stakes and deepens engagement" },
{ type: "Perspective Shift", expectedPosition: 0.75, emotionalExperience: "Paradigm change", storyFunction: "Forces reevaluation" },
{ type: "Transcendent Integration", expectedPosition: 0.90, emotionalExperience: "Meaning-making", storyFunction: "Provides thematic resolution" },
],
mystery: [
{ type: "Question Inception", expectedPosition: 0.10, emotionalExperience: "Curiosity activation", storyFunction: "Establishes the puzzle" },
{ type: "Pattern Recognition", expectedPosition: 0.35, emotionalExperience: "Satisfaction of connection", storyFunction: "Provides momentum" },
{ type: "False Resolution", expectedPosition: 0.55, emotionalExperience: "Surprise from misdirection", storyFunction: "Creates complexity" },
{ type: "Progressive Revelation", expectedPosition: 0.75, emotionalExperience: "Deepening understanding", storyFunction: "Builds toward solution" },
{ type: "Solution Crystallization", expectedPosition: 0.90, emotionalExperience: "Illumination and closure", storyFunction: "Completes emotional journey" },
],
adventure: [
{ type: "Threshold Crossing", expectedPosition: 0.15, emotionalExperience: "Excitement of departure", storyFunction: "Transitions to adventure world" },
{ type: "Capability Test", expectedPosition: 0.30, emotionalExperience: "Confidence from competence", storyFunction: "Establishes abilities" },
{ type: "Resource Depletion", expectedPosition: 0.50, emotionalExperience: "Vulnerability from loss", storyFunction: "Forces adaptation" },
{ type: "Ultimate Challenge", expectedPosition: 0.80, emotionalExperience: "Fear and determination", storyFunction: "Tests protagonist's limits" },
{ type: "Return Transformation", expectedPosition: 0.95, emotionalExperience: "Pride and perspective", storyFunction: "Demonstrates growth" },
],
horror: [
{ type: "Wrongness Glimpse", expectedPosition: 0.10, emotionalExperience: "Unease from dissonance", storyFunction: "Establishes threat potential" },
{ type: "Safety Violation", expectedPosition: 0.30, emotionalExperience: "Shock from boundary breach", storyFunction: "Demonstrates vulnerability" },
{ type: "Threat Escalation", expectedPosition: 0.50, emotionalExperience: "Escalating dread", storyFunction: "Raises stakes" },
{ type: "Failed Solution", expectedPosition: 0.70, emotionalExperience: "Despair from ineffectuality", storyFunction: "Deepens hopelessness" },
{ type: "Confrontation", expectedPosition: 0.90, emotionalExperience: "Terror meets courage", storyFunction: "Provides climactic moment" },
],
thriller: [
{ type: "Stakes Establishment", expectedPosition: 0.15, emotionalExperience: "Concern for outcome", storyFunction: "Sets up tension framework" },
{ type: "Deadline Imposition", expectedPosition: 0.25, emotionalExperience: "Anxiety from time pressure", storyFunction: "Creates urgency" },
{ type: "Near Miss", expectedPosition: 0.50, emotionalExperience: "Relief with lingering tension", storyFunction: "Maintains engagement" },
{ type: "Option Elimination", expectedPosition: 0.75, emotionalExperience: "Mounting pressure", storyFunction: "Forces harder choices" },
{ type: "Decision Under Duress", expectedPosition: 0.90, emotionalExperience: "Catharsis through action", storyFunction: "Provides climactic release" },
],
relationship: [
{ type: "Significant Connection", expectedPosition: 0.10, emotionalExperience: "Recognition of potential", storyFunction: "Establishes relationship basis" },
{ type: "Intimacy Deepening", expectedPosition: 0.35, emotionalExperience: "Warmth from vulnerability", storyFunction: "Develops investment" },
{ type: "Value Conflict", expectedPosition: 0.55, emotionalExperience: "Frustration from differences", storyFunction: "Creates meaningful obstacles" },
{ type: "Relationship Crisis", expectedPosition: 0.75, emotionalExperience: "Heartbreak or betrayal", storyFunction: "Tests connection's resilience" },
{ type: "Reconciliation/Resolution", expectedPosition: 0.90, emotionalExperience: "Emotional closure", storyFunction: "Completes relationship arc" },
],
drama: [
{ type: "Internal Conflict Revelation", expectedPosition: 0.15, emotionalExperience: "Recognition of contradiction", storyFunction: "Establishes character struggle" },
{ type: "External Pressure Point", expectedPosition: 0.35, emotionalExperience: "Stress from circumstances", storyFunction: "Forces character choices" },
{ type: "Failure Moment", expectedPosition: 0.55, emotionalExperience: "Shame from inadequacy", storyFunction: "Deepens character journey" },
{ type: "Truth Confrontation", expectedPosition: 0.75, emotionalExperience: "Painful self-awareness", storyFunction: "Catalyzes change" },
{ type: "Character Evolution", expectedPosition: 0.90, emotionalExperience: "Self-actualization", storyFunction: "Demonstrates growth" },
],
issue: [
{ type: "Perspective Challenge", expectedPosition: 0.15, emotionalExperience: "Intellectual discomfort", storyFunction: "Establishes complexity" },
{ type: "Stake Personalization", expectedPosition: 0.30, emotionalExperience: "Emotional investment", storyFunction: "Makes abstract concrete" },
{ type: "Complexity Recognition", expectedPosition: 0.50, emotionalExperience: "Cognitive expansion", storyFunction: "Prevents simplistic resolution" },
{ type: "Position Testing", expectedPosition: 0.70, emotionalExperience: "Value examination", storyFunction: "Forces intellectual honesty" },
{ type: "Perspective Integration", expectedPosition: 0.90, emotionalExperience: "Nuanced understanding", storyFunction: "Provides thematic resolution" },
],
ensemble: [
{ type: "Group Formation", expectedPosition: 0.15, emotionalExperience: "Belonging potential", storyFunction: "Establishes the collective" },
{ type: "Role Establishment", expectedPosition: 0.30, emotionalExperience: "Identity within community", storyFunction: "Defines character functions" },
{ type: "Group Fracture", expectedPosition: 0.55, emotionalExperience: "Loyalty testing", storyFunction: "Creates internal conflict" },
{ type: "Collective Challenge", expectedPosition: 0.75, emotionalExperience: "Shared adversity", storyFunction: "Forces cooperation" },
{ type: "Synergy Moment", expectedPosition: 0.90, emotionalExperience: "Strength through unity", storyFunction: "Demonstrates group value" },
],
humor: [
{ type: "Setup Establishment", expectedPosition: 0.10, emotionalExperience: "Anticipation", storyFunction: "Creates comedic potential" },
{ type: "Incongruity Introduction", expectedPosition: 0.25, emotionalExperience: "Surprise amusement", storyFunction: "Establishes comedic logic" },
{ type: "Escalation Sequence", expectedPosition: 0.50, emotionalExperience: "Mounting absurdity", storyFunction: "Builds comedic momentum" },
{ type: "Subversion Peak", expectedPosition: 0.75, emotionalExperience: "Maximum comedic release", storyFunction: "Delivers primary payoff" },
{ type: "Resolution Callback", expectedPosition: 0.90, emotionalExperience: "Satisfying closure", storyFunction: "Ties comedic threads" },
],
idea: [
{ type: "Concept Introduction", expectedPosition: 0.10, emotionalExperience: "Intellectual curiosity", storyFunction: "Establishes central idea" },
{ type: "Implication Exploration", expectedPosition: 0.30, emotionalExperience: "Fascination with consequences", storyFunction: "Expands idea's scope" },
{ type: "Edge Case Discovery", expectedPosition: 0.50, emotionalExperience: "Surprise from complexity", storyFunction: "Deepens understanding" },
{ type: "Idea Testing", expectedPosition: 0.70, emotionalExperience: "Tension from stakes", storyFunction: "Makes abstract personal" },
{ type: "Synthesis Resolution", expectedPosition: 0.90, emotionalExperience: "Intellectual satisfaction", storyFunction: "Completes thought experiment" },
],
};
// === UTILITIES ===
function countPatternMatches(text: string, patterns: RegExp[]): { count: number; indicators: string[] } {
const indicators: string[] = [];
let totalCount = 0;
for (const pattern of patterns) {
const matches = text.match(pattern);
if (matches) {
totalCount += matches.length;
indicators.push(...matches.slice(0, 3));
}
}
return {
count: totalCount,
indicators: [...new Set(indicators)].slice(0, 10),
};
}
function sampleText(text: string, sampleSize: number = 10): string[] {
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 100);
if (paragraphs.length <= sampleSize) {
return paragraphs;
}
// Sample from beginning, middle, and end
const samples: string[] = [];
const thirdSize = Math.floor(sampleSize / 3);
// Beginning
for (let i = 0; i < thirdSize && i < paragraphs.length; i++) {
samples.push(paragraphs[i]);
}
// Middle
const middleStart = Math.floor(paragraphs.length / 2) - Math.floor(thirdSize / 2);
for (let i = middleStart; i < middleStart + thirdSize && i < paragraphs.length; i++) {
if (!samples.includes(paragraphs[i])) {
samples.push(paragraphs[i]);
}
}
// End
const endStart = paragraphs.length - thirdSize;
for (let i = endStart; i < paragraphs.length; i++) {
if (!samples.includes(paragraphs[i])) {
samples.push(paragraphs[i]);
}
}
return samples;
}
// === CORE LOGIC ===
function detectGenre(text: string, sampleSize: number = 10): GenreDetectionResult {
// Sample text to analyze
const samples = sampleText(text, sampleSize);
const sampledText = samples.join("\n\n");
// Count matches for each genre
const evidence: Record<string, GenreEvidence> = {};
const scores: { genre: string; score: number }[] = [];
for (const [genre, patterns] of Object.entries(GENRE_PATTERNS)) {
const result = countPatternMatches(sampledText, patterns);
evidence[genre] = {
count: result.count,
indicators: result.indicators,
};
scores.push({ genre, score: result.count });
}
// Sort by score
scores.sort((a, b) => b.score - a.score);
// Calculate confidence based on how much higher primary is than others
const totalScore = scores.reduce((sum, s) => sum + s.score, 0) || 1;
const primaryScore = scores[0]?.score || 0;
const primaryConfidence = Math.min(1, (primaryScore / totalScore) + (primaryScore > 10 ? 0.2 : 0));
// Get secondary genres (those with > 30% of primary's score)
const secondaryGenres = scores
.slice(1, 4)
.filter(s => s.score > primaryScore * 0.3)
.map(s => s.genre);
// Get Key Moments for primary genre
const primaryGenre = scores[0]?.genre || "drama"; // Default to drama if unclear
const keyMomentTemplates = KEY_MOMENTS[primaryGenre] || KEY_MOMENTS.drama;
const expectedKeyMoments: KeyMoment[] = keyMomentTemplates.map(km => ({
...km,
foundAt: null,
foundScene: null,
}));
return {
primaryGenre,
primaryConfidence: Math.round(primaryConfidence * 100) / 100,
secondaryGenres,
evidence,
keyMomentsFramework: primaryGenre,
expectedKeyMoments,
detectedAt: new Date().toISOString(),
};
}
// === CLI ===
function printHelp(): void {
console.log(`Genre Detection Script
Usage:
deno run --allow-read scripts/detect-genre.ts <book.txt> [options]
Options:
--output <file> Write output to JSON file (default: stdout)
--sample-size <n> Number of text samples to analyze (default: 10)
--help, -h Show this help message
Examples:
deno run --allow-read detect-genre.ts novel.txt
deno run --allow-read detect-genre.ts novel.txt --output genre.json
deno run --allow-read detect-genre.ts novel.txt --sample-size 20
`);
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
printHelp();
Deno.exit(0);
}
// Parse arguments
let inputFile = "";
let outputFile = "";
let sampleSize = 10;
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--output" && args[i + 1]) {
outputFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--sample-size" && args[i + 1]) {
sampleSize = parseInt(args[i + 1], 10) || 10;
skipIndices.add(i + 1);
} else if (!args[i].startsWith("--") && !inputFile) {
inputFile = args[i];
}
}
if (!inputFile) {
console.error("Error: No input file provided");
Deno.exit(1);
}
// Read input file
let text: string;
try {
text = await Deno.readTextFile(inputFile);
} catch (e) {
console.error(`Error reading file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
// Detect genre
const result = detectGenre(text, sampleSize);
// Output
const jsonOutput = JSON.stringify(result, null, 2);
if (outputFile) {
await Deno.writeTextFile(outputFile, jsonOutput);
console.log(`Genre detection complete`);
console.log(`Primary: ${result.primaryGenre} (${Math.round(result.primaryConfidence * 100)}% confidence)`);
if (result.secondaryGenres.length > 0) {
console.log(`Secondary: ${result.secondaryGenres.join(", ")}`);
}
console.log(`Output written to: ${outputFile}`);
} else {
console.log(jsonOutput);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Outline Generation Script
*
* Synthesizes all analysis JSON files into a structured markdown outline.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-outline.ts [options]
*/
// === INTERFACES ===
// From segment-book.ts
interface SegmentScene {
id: string;
startLine: number;
endLine: number;
wordCount: number;
openingText: string;
povCandidate: string | null;
}
interface SegmentChapter {
number: number;
title: string;
startLine: number;
endLine: number;
wordCount: number;
scenes: SegmentScene[];
}
interface SegmentationResult {
title: string;
totalWords: number;
chapters: SegmentChapter[];
metadata: {
totalChapters: number;
totalScenes: number;
avgSceneLength: number;
segmentedAt: string;
};
}
// From analyze-scene-batch.ts
interface ElementAnalysis {
detected: boolean;
confidence: number;
indicators: string[];
summary: string;
}
interface SceneAnalysis {
id: string;
chapterNumber: number;
pov: string | null;
wordCount: number;
structure: {
goal: ElementAnalysis;
conflict: ElementAnalysis;
disaster: ElementAnalysis & { type: string };
};
sequelElements: {
reaction: boolean;
dilemma: boolean;
decision: boolean;
};
sceneSequelRatio: number;
pacing: "action-heavy" | "balanced" | "reflective";
function: string;
issues: string[];
}
interface BatchAnalysisResult {
title: string;
analyzedAt: string;
scenes: SceneAnalysis[];
summary: {
totalScenes: number;
avgConfidence: number;
pacingDistribution: {
actionHeavy: number;
balanced: number;
reflective: number;
};
issuesCount: number;
};
}
// From detect-genre.ts
interface KeyMoment {
type: string;
expectedPosition: number;
emotionalExperience: string;
storyFunction: string;
foundAt: number | null;
foundScene: string | null;
}
interface GenreDetectionResult {
primaryGenre: string;
primaryConfidence: number;
secondaryGenres: string[];
evidence: Record<string, { count: number; indicators: string[] }>;
keyMomentsFramework: string;
expectedKeyMoments: KeyMoment[];
detectedAt: string;
}
// From track-characters.ts
interface ArcComponents {
lie: string | null;
lieEvidence: string[];
want: string | null;
need: string | null;
ghost: string | null;
ghostScene: string | null;
truthAcceptance: string | null;
transformation: string | null;
}
interface CharacterInfo {
name: string;
firstAppearance: string;
povScenes: number;
mentions: number;
arcType: "positive" | "negative" | "flat" | "unclear";
arcComponents: ArcComponents;
keyScenes: string[];
role: string;
}
interface CharacterTrackingResult {
protagonist: CharacterInfo | null;
secondaryCharacters: CharacterInfo[];
characterWeb: Record<string, string>;
trackedAt: string;
}
// === UTILITIES ===
function formatConfidence(confidence: number): string {
const pct = Math.round(confidence * 100);
if (pct >= 70) return `${pct}% (high)`;
if (pct >= 40) return `${pct}% (medium)`;
return `${pct}% (low)`;
}
function formatPacing(pacing: string): string {
switch (pacing) {
case "action-heavy":
return "Action-heavy (more scene than sequel)";
case "reflective":
return "Reflective (more sequel than scene)";
default:
return "Balanced";
}
}
// === GENERATION ===
function generateOutline(
segments: SegmentationResult,
scenes: BatchAnalysisResult,
genre: GenreDetectionResult,
characters: CharacterTrackingResult,
depth: "summary" | "standard" | "detailed" = "standard"
): string {
const lines: string[] = [];
const today = new Date().toISOString().split("T")[0];
// === HEADER ===
lines.push(`# Reverse Outline: ${segments.title}`);
lines.push("");
lines.push(`**Generated:** ${today}`);
lines.push(`**Analysis Depth:** ${depth}`);
lines.push("");
lines.push("---");
lines.push("");
// === OVERVIEW ===
lines.push("## Overview");
lines.push("");
lines.push(`**Genre:** ${genre.primaryGenre} (${Math.round(genre.primaryConfidence * 100)}% confidence)`);
if (genre.secondaryGenres.length > 0) {
lines.push(`**Secondary Genres:** ${genre.secondaryGenres.join(", ")}`);
}
lines.push(`**Total Words:** ${segments.totalWords.toLocaleString()}`);
lines.push(`**Chapters:** ${segments.metadata.totalChapters}`);
lines.push(`**Scenes:** ${segments.metadata.totalScenes}`);
lines.push(`**Avg Scene Length:** ${segments.metadata.avgSceneLength.toLocaleString()} words`);
lines.push("");
// Pacing distribution
const pacing = scenes.summary.pacingDistribution;
lines.push("**Pacing Distribution:**");
lines.push(`- Action-heavy scenes: ${pacing.actionHeavy} (${Math.round(pacing.actionHeavy / scenes.summary.totalScenes * 100)}%)`);
lines.push(`- Balanced scenes: ${pacing.balanced} (${Math.round(pacing.balanced / scenes.summary.totalScenes * 100)}%)`);
lines.push(`- Reflective scenes: ${pacing.reflective} (${Math.round(pacing.reflective / scenes.summary.totalScenes * 100)}%)`);
lines.push("");
// === KEY MOMENTS MAP ===
lines.push("## Key Moments Map");
lines.push("");
lines.push(`Framework: ${genre.keyMomentsFramework}`);
lines.push("");
lines.push("| Key Moment | Expected Position | Emotional Experience | Story Function |");
lines.push("|------------|-------------------|---------------------|----------------|");
for (const km of genre.expectedKeyMoments) {
const expectedPct = Math.round(km.expectedPosition * 100);
lines.push(`| ${km.type} | ${expectedPct}% | ${km.emotionalExperience} | ${km.storyFunction} |`);
}
lines.push("");
// === CHARACTER ARCS ===
lines.push("## Character Arcs");
lines.push("");
if (characters.protagonist) {
const p = characters.protagonist;
lines.push(`### Protagonist: ${p.name}`);
lines.push("");
lines.push(`**Arc Type:** ${p.arcType}`);
lines.push(`**POV Scenes:** ${p.povScenes}`);
lines.push(`**First Appearance:** ${p.firstAppearance}`);
lines.push("");
if (p.arcComponents.lie) {
lines.push(`**Lie:** ${p.arcComponents.lie}`);
}
if (p.arcComponents.want) {
lines.push(`**Want:** ${p.arcComponents.want}`);
}
if (p.arcComponents.need) {
lines.push(`**Need:** ${p.arcComponents.need}`);
}
if (p.arcComponents.ghost) {
lines.push(`**Ghost/Backstory:** ${p.arcComponents.ghost}`);
}
if (p.arcComponents.truthAcceptance) {
lines.push(`**Truth Acceptance:** ${p.arcComponents.truthAcceptance}`);
}
if (p.arcComponents.transformation) {
lines.push(`**Transformation:** ${p.arcComponents.transformation}`);
}
if (p.keyScenes.length > 0) {
lines.push(`**Key Scenes:** ${p.keyScenes.join(", ")}`);
}
lines.push("");
}
if (characters.secondaryCharacters.length > 0) {
lines.push("### Secondary Characters");
lines.push("");
for (const c of characters.secondaryCharacters) {
lines.push(`#### ${c.name}`);
lines.push(`- **Role:** ${c.role}`);
lines.push(`- **Arc Type:** ${c.arcType}`);
lines.push(`- **POV Scenes:** ${c.povScenes}`);
if (c.keyScenes.length > 0) {
lines.push(`- **Key Scenes:** ${c.keyScenes.join(", ")}`);
}
lines.push("");
}
}
lines.push("---");
lines.push("");
// === CHAPTER-BY-CHAPTER BREAKDOWN ===
lines.push("## Chapter-by-Chapter Breakdown");
lines.push("");
// Build scene lookup
const sceneMap = new Map<string, SceneAnalysis>();
for (const scene of scenes.scenes) {
sceneMap.set(scene.id, scene);
}
for (const chapter of segments.chapters) {
lines.push(`### Chapter ${chapter.number}: ${chapter.title}`);
lines.push("");
lines.push(`**Word Count:** ${chapter.wordCount.toLocaleString()}`);
lines.push(`**Scenes:** ${chapter.scenes.length}`);
lines.push("");
for (const segScene of chapter.scenes) {
const analysis = sceneMap.get(segScene.id);
lines.push(`#### Scene ${segScene.id} | POV: ${segScene.povCandidate || "Unknown"}`);
lines.push("");
if (analysis) {
lines.push(`**Function:** ${analysis.function}`);
lines.push("");
if (depth === "detailed" || depth === "standard") {
lines.push("| Element | Analysis |");
lines.push("|---------|----------|");
lines.push(`| Goal | ${analysis.structure.goal.detected ? analysis.structure.goal.summary : "Not detected"} |`);
lines.push(`| Conflict | ${analysis.structure.conflict.detected ? analysis.structure.conflict.summary : "Not detected"} |`);
lines.push(`| Disaster | ${analysis.structure.disaster.detected ? `${analysis.structure.disaster.type}: ${analysis.structure.disaster.summary}` : "Not detected"} |`);
const sequelParts = [];
if (analysis.sequelElements.reaction) sequelParts.push("Reaction");
if (analysis.sequelElements.dilemma) sequelParts.push("Dilemma");
if (analysis.sequelElements.decision) sequelParts.push("Decision");
lines.push(`| Sequel | ${sequelParts.length > 0 ? sequelParts.join(", ") : "None detected"} |`);
lines.push("");
}
lines.push(`**Pacing:** ${formatPacing(analysis.pacing)}`);
lines.push(`**Words:** ${analysis.wordCount.toLocaleString()}`);
if (depth === "detailed" && analysis.issues.length > 0) {
lines.push("");
lines.push("**Issues:**");
for (const issue of analysis.issues) {
lines.push(`- ${issue}`);
}
}
} else {
lines.push(`**Opening:** ${segScene.openingText}`);
lines.push(`**Words:** ${segScene.wordCount.toLocaleString()}`);
}
lines.push("");
lines.push("---");
lines.push("");
}
}
// === APPENDIX A: SCENE INDEX BY FUNCTION ===
lines.push("## Appendix A: Scene Index by Function");
lines.push("");
// Group scenes by structural function
const functionGroups = new Map<string, string[]>();
for (const scene of scenes.scenes) {
const funcKey = scene.function.split(" - ")[0]; // Get main function
if (!functionGroups.has(funcKey)) {
functionGroups.set(funcKey, []);
}
functionGroups.get(funcKey)!.push(scene.id);
}
lines.push("| Function | Scenes |");
lines.push("|----------|--------|");
const functionOrder = [
"Opening scene",
"Setup",
"Inciting incident",
"First plot point",
"Rising action",
"Midpoint",
"Complications",
"Dark night",
"Climax",
"Resolution",
];
for (const func of functionOrder) {
const matching = Array.from(functionGroups.entries())
.filter(([key]) => key.toLowerCase().includes(func.toLowerCase()));
for (const [key, sceneIds] of matching) {
lines.push(`| ${key} | ${sceneIds.join(", ")} |`);
}
}
// Add any functions not in the standard order
for (const [func, sceneIds] of functionGroups.entries()) {
const isStandard = functionOrder.some(f => func.toLowerCase().includes(f.toLowerCase()));
if (!isStandard) {
lines.push(`| ${func} | ${sceneIds.join(", ")} |`);
}
}
lines.push("");
// === APPENDIX B: CHARACTER SCENE TRACKER ===
lines.push("## Appendix B: Character Scene Tracker");
lines.push("");
lines.push("| Character | POV Scenes | Arc Type | Key Scenes |");
lines.push("|-----------|------------|----------|------------|");
if (characters.protagonist) {
const p = characters.protagonist;
lines.push(`| **${p.name}** (protagonist) | ${p.povScenes} | ${p.arcType} | ${p.keyScenes.slice(0, 5).join(", ")} |`);
}
for (const c of characters.secondaryCharacters) {
lines.push(`| ${c.name} | ${c.povScenes} | ${c.arcType} | ${c.keyScenes.slice(0, 3).join(", ")} |`);
}
lines.push("");
// === APPENDIX C: PACING ANALYSIS ===
if (depth === "detailed") {
lines.push("## Appendix C: Pacing Analysis");
lines.push("");
lines.push("Scene-by-scene pacing showing scene/sequel ratio:");
lines.push("");
for (const scene of scenes.scenes) {
const bar = "=".repeat(Math.round(scene.sceneSequelRatio * 20));
const space = " ".repeat(20 - bar.length);
lines.push(`${scene.id}: [${bar}${space}] ${Math.round(scene.sceneSequelRatio * 100)}% scene`);
}
lines.push("");
}
// === FOOTER ===
lines.push("---");
lines.push("");
lines.push("*Generated by reverse-outliner skill*");
return lines.join("\n");
}
// === CLI ===
function printHelp(): void {
console.log(`Outline Generation Script
Usage:
deno run --allow-read --allow-write scripts/generate-outline.ts [options]
Required Options:
--segments <file> Segments JSON from segment-book.ts
--scenes <file> Scene analysis JSON from analyze-scene-batch.ts
--genre <file> Genre detection JSON from detect-genre.ts
--characters <file> Character tracking JSON from track-characters.ts
Other Options:
--output <file> Output markdown file (default: stdout)
--depth <level> Output depth: summary, standard, detailed (default: standard)
--help, -h Show this help message
Examples:
deno run --allow-read --allow-write generate-outline.ts \\
--segments analysis/segments.json \\
--scenes analysis/scenes.json \\
--genre analysis/genre.json \\
--characters analysis/characters.json \\
--output outline.md
`);
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h")) {
printHelp();
Deno.exit(0);
}
// Parse arguments
let segmentsFile = "";
let scenesFile = "";
let genreFile = "";
let charactersFile = "";
let outputFile = "";
let depth: "summary" | "standard" | "detailed" = "standard";
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--segments" && args[i + 1]) {
segmentsFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--scenes" && args[i + 1]) {
scenesFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--genre" && args[i + 1]) {
genreFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--characters" && args[i + 1]) {
charactersFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--output" && args[i + 1]) {
outputFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--depth" && args[i + 1]) {
const d = args[i + 1];
if (d === "summary" || d === "standard" || d === "detailed") {
depth = d;
}
skipIndices.add(i + 1);
}
}
// Validate required files
if (!segmentsFile || !scenesFile || !genreFile || !charactersFile) {
console.error("Error: All four input files are required (--segments, --scenes, --genre, --characters)");
printHelp();
Deno.exit(1);
}
// Read all input files
let segments: SegmentationResult;
let scenes: BatchAnalysisResult;
let genre: GenreDetectionResult;
let characters: CharacterTrackingResult;
try {
segments = JSON.parse(await Deno.readTextFile(segmentsFile));
scenes = JSON.parse(await Deno.readTextFile(scenesFile));
genre = JSON.parse(await Deno.readTextFile(genreFile));
characters = JSON.parse(await Deno.readTextFile(charactersFile));
} catch (e) {
console.error(`Error reading input files: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
// Generate outline
const outline = generateOutline(segments, scenes, genre, characters, depth);
// Output
if (outputFile) {
await Deno.writeTextFile(outputFile, outline);
console.log(`Outline generated: ${outputFile}`);
console.log(`Title: ${segments.title}`);
console.log(`Depth: ${depth}`);
} else {
console.log(outline);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run
/**
* Reverse Outline Orchestrator
*
* Runs the full pipeline from book.txt to outline.md:
* book.txt -> segment -> analyze -> detect genre -> track characters -> generate outline
*
* Usage:
* deno run --allow-read --allow-write --allow-run scripts/reverse-outline.ts book.txt
* deno run --allow-read --allow-write --allow-run scripts/reverse-outline.ts book.txt --output ./outlines/
*/
import { dirname, join, basename } from "https://deno.land/std@0.208.0/path/mod.ts";
import { ensureDir } from "https://deno.land/std@0.208.0/fs/mod.ts";
// === CONFIGURATION ===
const SCRIPT_DIR = dirname(new URL(import.meta.url).pathname);
// === UTILITIES ===
async function runScript(
scriptName: string,
args: string[],
description: string
): Promise<void> {
console.log(`\n[${description}]`);
const scriptPath = join(SCRIPT_DIR, scriptName);
const command = new Deno.Command("deno", {
args: [
"run",
"--allow-read",
"--allow-write",
scriptPath,
...args,
],
stdout: "inherit",
stderr: "inherit",
});
const { code } = await command.output();
if (code !== 0) {
throw new Error(`Script ${scriptName} failed with code ${code}`);
}
}
function inferBookName(inputPath: string): string {
const base = basename(inputPath);
return base.replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9-_]/g, "-");
}
// === MAIN ===
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
console.log(`Reverse Outline Orchestrator
Runs the complete reverse-outline pipeline:
1. Segment book into chapters and scenes
2. Analyze scene structure (Goal/Conflict/Disaster)
3. Detect genre and map Key Moments
4. Track character arcs
5. Generate markdown outline
Usage:
deno run --allow-read --allow-write --allow-run scripts/reverse-outline.ts <book.txt> [options]
Options:
--output <dir> Output directory (default: ./reverse-outlines/{book-name}/)
--depth <level> Analysis depth: quick, standard, detailed (default: standard)
--protagonist <name> Specify protagonist name (overrides detection)
--genre <type> Override genre detection
--help, -h Show this help message
Examples:
deno run --allow-read --allow-write --allow-run reverse-outline.ts novel.txt
deno run --allow-read --allow-write --allow-run reverse-outline.ts novel.txt --output ./outlines/
deno run --allow-read --allow-write --allow-run reverse-outline.ts novel.txt --protagonist "Sarah"
`);
Deno.exit(0);
}
// Parse arguments
let inputFile = "";
let outputDir = "";
let depth = "standard";
let protagonistName = "";
let genreOverride = "";
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--output" && args[i + 1]) {
outputDir = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--depth" && args[i + 1]) {
depth = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--protagonist" && args[i + 1]) {
protagonistName = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--genre" && args[i + 1]) {
genreOverride = args[i + 1];
skipIndices.add(i + 1);
} else if (!args[i].startsWith("--") && !inputFile) {
inputFile = args[i];
}
}
if (!inputFile) {
console.error("Error: No input file provided");
Deno.exit(1);
}
// Verify input file exists
try {
await Deno.stat(inputFile);
} catch {
console.error(`Error: Input file not found: ${inputFile}`);
Deno.exit(1);
}
// Set up output directory
const bookName = inferBookName(inputFile);
if (!outputDir) {
outputDir = `./reverse-outlines/${bookName}`;
}
const analysisDir = join(outputDir, "analysis");
await ensureDir(analysisDir);
console.log("=".repeat(60));
console.log(`Reverse Outline: ${bookName}`);
console.log("=".repeat(60));
console.log(`Input: ${inputFile}`);
console.log(`Output: ${outputDir}`);
console.log(`Depth: ${depth}`);
// File paths
const segmentsFile = join(analysisDir, "segments.json");
const scenesFile = join(analysisDir, "scenes.json");
const genreFile = join(analysisDir, "genre.json");
const charactersFile = join(analysisDir, "characters.json");
const outlineFile = join(outputDir, "outline.md");
try {
// Step 1: Segment book
await runScript("segment-book.ts", [
inputFile,
"--output", segmentsFile,
"--title", bookName,
], "Step 1/5: Segmenting book into chapters and scenes");
// Step 2: Analyze scenes
await runScript("analyze-scene-batch.ts", [
segmentsFile,
inputFile,
"--output", scenesFile,
"--depth", depth,
], "Step 2/5: Analyzing scene structure (Goal/Conflict/Disaster)");
// Step 3: Detect genre
await runScript("detect-genre.ts", [
inputFile,
"--output", genreFile,
], "Step 3/5: Detecting genre and mapping Key Moments");
// Step 4: Track characters
const characterArgs = [
segmentsFile,
inputFile,
"--output", charactersFile,
];
if (protagonistName) {
characterArgs.push("--protagonist", protagonistName);
}
await runScript("track-characters.ts", characterArgs,
"Step 4/5: Tracking character arcs");
// Step 5: Generate outline
await runScript("generate-outline.ts", [
"--segments", segmentsFile,
"--scenes", scenesFile,
"--genre", genreFile,
"--characters", charactersFile,
"--output", outlineFile,
"--depth", depth,
], "Step 5/5: Generating markdown outline");
console.log("\n" + "=".repeat(60));
console.log("COMPLETE");
console.log("=".repeat(60));
console.log(`\nOutput files:`);
console.log(` Outline: ${outlineFile}`);
console.log(` Segments: ${segmentsFile}`);
console.log(` Scenes: ${scenesFile}`);
console.log(` Genre: ${genreFile}`);
console.log(` Characters: ${charactersFile}`);
console.log("");
} catch (e) {
console.error(`\nPipeline failed: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Book Segmentation Script
*
* Parses raw book text into chapters and scenes.
* Detects chapters via regex patterns and scenes via break markers or POV shifts.
*
* Usage:
* deno run --allow-read scripts/segment-book.ts book.txt
* deno run --allow-read scripts/segment-book.ts book.txt --output segments.json
* deno run --allow-read scripts/segment-book.ts book.txt --chapter-pattern "^CHAPTER"
*/
// === INTERFACES ===
interface Scene {
id: string;
startLine: number;
endLine: number;
wordCount: number;
openingText: string;
povCandidate: string | null;
}
interface Chapter {
number: number;
title: string;
startLine: number;
endLine: number;
wordCount: number;
scenes: Scene[];
}
interface SegmentationResult {
title: string;
totalWords: number;
chapters: Chapter[];
metadata: {
totalChapters: number;
totalScenes: number;
avgSceneLength: number;
segmentedAt: string;
};
}
// === DEFAULT PATTERNS ===
const DEFAULT_CHAPTER_PATTERNS = [
/^(Chapter|CHAPTER)\s+\d+/i,
/^(Chapter|CHAPTER)\s+(One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Sixteen|Seventeen|Eighteen|Nineteen|Twenty)/i,
/^(PART|Part)\s+(ONE|TWO|THREE|I|II|III|IV|V|1|2|3|4|5)/i,
/^\d+\s*$/,
/^(PROLOGUE|EPILOGUE|Prologue|Epilogue)/i,
];
const DEFAULT_SCENE_BREAK_PATTERNS = [
/^\s*\*\s*\*\s*\*\s*$/,
/^\s*#\s*$/,
/^\s*---\s*$/,
/^\s*\* \* \*\s*$/,
/^\s*~+\s*$/,
];
// Common first names for POV detection
const COMMON_NAMES = new Set([
"John", "Jane", "Michael", "Sarah", "David", "Emily", "James", "Emma",
"Robert", "Anna", "William", "Maria", "Thomas", "Elizabeth", "Jack", "Kate",
"Alex", "Sam", "Chris", "Taylor", "Jordan", "Morgan", "Casey", "Quinn",
]);
// === UTILITIES ===
function countWords(text: string): number {
return text.split(/\s+/).filter(w => w.length > 0).length;
}
function extractOpeningText(lines: string[], maxChars: number = 150): string {
let text = "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
text += (text ? " " : "") + trimmed;
if (text.length >= maxChars) {
return text.substring(0, maxChars) + "...";
}
}
}
return text;
}
function detectPovCandidate(text: string): string | null {
// Look for proper nouns in first few sentences
const firstParagraph = text.split(/\n\s*\n/)[0] || text;
const sentences = firstParagraph.split(/[.!?]+/).slice(0, 3);
for (const sentence of sentences) {
// Look for capitalized words that might be names
const words = sentence.split(/\s+/);
for (const word of words) {
const cleaned = word.replace(/[^a-zA-Z]/g, "");
if (cleaned.length > 2 && /^[A-Z][a-z]+$/.test(cleaned)) {
// Check if it's a common name
if (COMMON_NAMES.has(cleaned)) {
return cleaned;
}
}
}
// Look for "I" as first-person POV indicator
if (/\bI\b/.test(sentence)) {
return "[First Person]";
}
}
// Second pass: look for any capitalized word after common patterns
const povPatterns = [
/\b([A-Z][a-z]+)\s+(?:thought|felt|knew|wondered|watched|looked|stared)/,
/\b([A-Z][a-z]+)'s\s+(?:eyes|heart|mind|stomach)/,
/\b([A-Z][a-z]+)\s+(?:was|had been|stood|sat)/,
];
for (const pattern of povPatterns) {
const match = firstParagraph.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
function isChapterHeading(line: string, patterns: RegExp[]): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
for (const pattern of patterns) {
if (pattern.test(trimmed)) {
return true;
}
}
return false;
}
function isSceneBreak(line: string, patterns: RegExp[]): boolean {
for (const pattern of patterns) {
if (pattern.test(line)) {
return true;
}
}
return false;
}
function isBlankLineCluster(lines: string[], index: number, threshold: number = 3): boolean {
let count = 0;
for (let i = index; i < lines.length && i < index + threshold + 2; i++) {
if (lines[i].trim() === "") {
count++;
} else {
break;
}
}
return count >= threshold;
}
// === CORE LOGIC ===
function segmentBook(
text: string,
options: {
chapterPatterns?: RegExp[];
sceneBreakPatterns?: RegExp[];
blankLineThreshold?: number;
title?: string;
} = {}
): SegmentationResult {
const chapterPatterns = options.chapterPatterns || DEFAULT_CHAPTER_PATTERNS;
const sceneBreakPatterns = options.sceneBreakPatterns || DEFAULT_SCENE_BREAK_PATTERNS;
const blankLineThreshold = options.blankLineThreshold || 3;
const lines = text.split("\n");
const chapters: Chapter[] = [];
let currentChapter: Chapter | null = null;
let currentSceneStartLine = 0;
let sceneCounter = 0;
let chapterNumber = 0;
// First pass: find chapter boundaries
const chapterBoundaries: { line: number; title: string }[] = [];
for (let i = 0; i < lines.length; i++) {
if (isChapterHeading(lines[i], chapterPatterns)) {
chapterBoundaries.push({
line: i,
title: lines[i].trim(),
});
}
}
// If no chapters found, treat entire book as one chapter
if (chapterBoundaries.length === 0) {
chapterBoundaries.push({
line: 0,
title: "Full Text",
});
}
// Process each chapter
for (let chapterIdx = 0; chapterIdx < chapterBoundaries.length; chapterIdx++) {
const chapterStart = chapterBoundaries[chapterIdx].line;
const chapterEnd = chapterIdx < chapterBoundaries.length - 1
? chapterBoundaries[chapterIdx + 1].line - 1
: lines.length - 1;
chapterNumber++;
const scenes: Scene[] = [];
let sceneStartLine = chapterStart + 1; // Skip chapter heading
// Find scene breaks within chapter
for (let i = sceneStartLine; i <= chapterEnd; i++) {
const isBreak = isSceneBreak(lines[i], sceneBreakPatterns) ||
isBlankLineCluster(lines, i, blankLineThreshold);
if (isBreak || i === chapterEnd) {
// End current scene
const sceneEndLine = isBreak ? i - 1 : i;
if (sceneEndLine >= sceneStartLine) {
const sceneLines = lines.slice(sceneStartLine, sceneEndLine + 1);
const sceneText = sceneLines.join("\n");
if (countWords(sceneText) > 10) { // Skip very short segments
sceneCounter++;
scenes.push({
id: `ch${chapterNumber}-s${scenes.length + 1}`,
startLine: sceneStartLine + 1, // Convert to 1-based
endLine: sceneEndLine + 1,
wordCount: countWords(sceneText),
openingText: extractOpeningText(sceneLines),
povCandidate: detectPovCandidate(sceneText),
});
}
}
// Start new scene after the break
if (isBlankLineCluster(lines, i, blankLineThreshold)) {
// Skip past all blank lines
while (i <= chapterEnd && lines[i].trim() === "") {
i++;
}
sceneStartLine = i;
i--; // Adjust for loop increment
} else {
sceneStartLine = i + 1;
}
}
}
const chapterLines = lines.slice(chapterStart, chapterEnd + 1);
const chapterText = chapterLines.join("\n");
chapters.push({
number: chapterNumber,
title: chapterBoundaries[chapterIdx].title,
startLine: chapterStart + 1,
endLine: chapterEnd + 1,
wordCount: countWords(chapterText),
scenes: scenes.length > 0 ? scenes : [{
id: `ch${chapterNumber}-s1`,
startLine: chapterStart + 2,
endLine: chapterEnd + 1,
wordCount: countWords(chapterText),
openingText: extractOpeningText(chapterLines.slice(1)),
povCandidate: detectPovCandidate(chapterText),
}],
});
}
const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
const totalScenes = chapters.reduce((sum, ch) => sum + ch.scenes.length, 0);
return {
title: options.title || "Untitled",
totalWords,
chapters,
metadata: {
totalChapters: chapters.length,
totalScenes,
avgSceneLength: totalScenes > 0 ? Math.round(totalWords / totalScenes) : 0,
segmentedAt: new Date().toISOString(),
},
};
}
// === CLI ===
function printHelp(): void {
console.log(`Book Segmentation Script
Usage:
deno run --allow-read scripts/segment-book.ts <book.txt> [options]
Options:
--output <file> Write output to JSON file (default: stdout)
--chapter-pattern <rx> Custom chapter detection regex
--scene-break <marker> Custom scene break marker (e.g., "***")
--blank-threshold <n> Number of blank lines to treat as scene break (default: 3)
--title <title> Book title for output metadata
--help, -h Show this help message
Examples:
deno run --allow-read segment-book.ts novel.txt
deno run --allow-read segment-book.ts novel.txt --output segments.json
deno run --allow-read segment-book.ts novel.txt --chapter-pattern "^CHAPTER"
`);
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
printHelp();
Deno.exit(0);
}
// Parse arguments
let inputFile = "";
let outputFile = "";
let customChapterPattern: RegExp | null = null;
let customSceneBreak = "";
let blankThreshold = 3;
let title = "";
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--output" && args[i + 1]) {
outputFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--chapter-pattern" && args[i + 1]) {
customChapterPattern = new RegExp(args[i + 1], "i");
skipIndices.add(i + 1);
} else if (args[i] === "--scene-break" && args[i + 1]) {
customSceneBreak = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--blank-threshold" && args[i + 1]) {
blankThreshold = parseInt(args[i + 1], 10) || 3;
skipIndices.add(i + 1);
} else if (args[i] === "--title" && args[i + 1]) {
title = args[i + 1];
skipIndices.add(i + 1);
} else if (!args[i].startsWith("--") && !inputFile) {
inputFile = args[i];
}
}
if (!inputFile) {
console.error("Error: No input file provided");
Deno.exit(1);
}
// Read input file
let text: string;
try {
text = await Deno.readTextFile(inputFile);
} catch (e) {
console.error(`Error reading file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
// Infer title from filename if not provided
if (!title) {
title = inputFile.split("/").pop()?.replace(/\.[^.]+$/, "") || "Untitled";
}
// Build options
const options: Parameters<typeof segmentBook>[1] = {
title,
blankLineThreshold: blankThreshold,
};
if (customChapterPattern) {
options.chapterPatterns = [customChapterPattern, ...DEFAULT_CHAPTER_PATTERNS];
}
if (customSceneBreak) {
const escapedBreak = customSceneBreak.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
options.sceneBreakPatterns = [
new RegExp(`^\\s*${escapedBreak}\\s*$`),
...DEFAULT_SCENE_BREAK_PATTERNS,
];
}
// Segment the book
const result = segmentBook(text, options);
// Output
const jsonOutput = JSON.stringify(result, null, 2);
if (outputFile) {
await Deno.writeTextFile(outputFile, jsonOutput);
console.log(`Segmentation complete: ${result.metadata.totalChapters} chapters, ${result.metadata.totalScenes} scenes`);
console.log(`Output written to: ${outputFile}`);
} else {
console.log(jsonOutput);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Character Tracking Script
*
* Identifies protagonist and major characters, tracks their arcs.
* Uses patterns from character-arc skill.
*
* Usage:
* deno run --allow-read scripts/track-characters.ts segments.json book.txt
* deno run --allow-read scripts/track-characters.ts segments.json book.txt --output characters.json
*/
// === INTERFACES ===
interface SegmentScene {
id: string;
startLine: number;
endLine: number;
wordCount: number;
openingText: string;
povCandidate: string | null;
}
interface SegmentChapter {
number: number;
title: string;
startLine: number;
endLine: number;
wordCount: number;
scenes: SegmentScene[];
}
interface SegmentationResult {
title: string;
totalWords: number;
chapters: SegmentChapter[];
metadata: {
totalChapters: number;
totalScenes: number;
avgSceneLength: number;
segmentedAt: string;
};
}
interface ArcComponents {
lie: string | null;
lieEvidence: string[];
want: string | null;
need: string | null;
ghost: string | null;
ghostScene: string | null;
truthAcceptance: string | null;
transformation: string | null;
}
interface CharacterInfo {
name: string;
firstAppearance: string;
povScenes: number;
mentions: number;
arcType: "positive" | "negative" | "flat" | "unclear";
arcComponents: ArcComponents;
keyScenes: string[];
role: string;
}
interface CharacterTrackingResult {
protagonist: CharacterInfo | null;
secondaryCharacters: CharacterInfo[];
characterWeb: Record<string, string>;
trackedAt: string;
}
// === PATTERNS ===
// Patterns for detecting arc elements
const LIE_PATTERNS = [
/\b(always believed|never thought|everyone knew|I know that|it's just how|the world is)\b/gi,
/\b(can't trust|don't need|won't ever|could never)\b/gi,
/\b(believed that|convinced that|certain that)\b/gi,
];
const WANT_PATTERNS = [
/\b(wanted nothing more|goal was|dream of|hoped to|wished for)\b/gi,
/\b(must find|must get|must have|need to)\b/gi,
/\b(desperate to|determined to|obsessed with)\b/gi,
];
const NEED_PATTERNS = [
/\b(really needed|actually need|what .+ truly|deep down)\b/gi,
/\b(connection|trust|acceptance|love|forgiveness)\b/gi,
];
const GHOST_PATTERNS = [
/\b(remembered|memory|flashback|years ago|back when)\b/gi,
/\b(never forgot|haunted by|couldn't forget|still remembered)\b/gi,
/\b(reminded .+ of|took .+ back to)\b/gi,
];
const TRUTH_PATTERNS = [
/\b(finally understood|realized that|saw clearly|for the first time)\b/gi,
/\b(had been wrong|changed .+ mind|no longer believed)\b/gi,
/\b(truth was|saw the truth|understood now)\b/gi,
];
const TRANSFORMATION_PATTERNS = [
/\b(different person|changed|transformed|no longer the same)\b/gi,
/\b(let go of|accepted that|embraced)\b/gi,
/\b(chose to|decided to|committed to)\b/gi,
];
// POV indicators for scoring
const POV_INDICATORS = [
/\bthought\b/gi,
/\bfelt\b/gi,
/\bknew\b/gi,
/\bwondered\b/gi,
/\brealized\b/gi,
];
// === UTILITIES ===
function extractSceneText(bookText: string, startLine: number, endLine: number): string {
const lines = bookText.split("\n");
return lines.slice(startLine - 1, endLine).join("\n");
}
function countNameMentions(text: string, name: string): number {
const pattern = new RegExp(`\\b${name}\\b`, "gi");
const matches = text.match(pattern);
return matches ? matches.length : 0;
}
function extractProperNouns(text: string): Map<string, number> {
const names = new Map<string, number>();
// Match capitalized words that look like names
const namePattern = /\b([A-Z][a-z]{2,})\b/g;
let match;
while ((match = namePattern.exec(text)) !== null) {
const name = match[1];
// Filter out common non-name words
const nonNames = new Set([
"The", "This", "That", "There", "These", "Those", "When", "Where",
"What", "Which", "While", "After", "Before", "During", "Through",
"Chapter", "Part", "One", "Two", "Three", "First", "Second", "Third",
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
"January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December",
]);
if (!nonNames.has(name)) {
names.set(name, (names.get(name) || 0) + 1);
}
}
return names;
}
function findPovScore(text: string, name: string): number {
let score = 0;
// Check for "Name thought/felt/knew" patterns
for (const pattern of POV_INDICATORS) {
const namePattern = new RegExp(`\\b${name}\\s+${pattern.source}`, "gi");
const matches = text.match(namePattern);
if (matches) {
score += matches.length * 2;
}
}
// Check for "'s thoughts/feelings" patterns
const possessivePatterns = [
new RegExp(`\\b${name}'s\\s+(thoughts?|mind|heart|eyes|stomach)`, "gi"),
new RegExp(`\\b${name}'s\\s+(face|expression|voice)\\s+(showed|revealed|betrayed)`, "gi"),
];
for (const pattern of possessivePatterns) {
const matches = text.match(pattern);
if (matches) {
score += matches.length;
}
}
return score;
}
function findPatternEvidence(text: string, patterns: RegExp[], name: string): string[] {
const evidence: string[] = [];
for (const pattern of patterns) {
// Look for pattern near character name
const sentences = text.split(/[.!?]+/);
for (const sentence of sentences) {
if (sentence.includes(name) && pattern.test(sentence)) {
const cleaned = sentence.trim().substring(0, 100);
if (cleaned && !evidence.includes(cleaned)) {
evidence.push(cleaned + (sentence.length > 100 ? "..." : ""));
}
}
}
}
return evidence.slice(0, 3);
}
function inferArcType(arcComponents: ArcComponents): "positive" | "negative" | "flat" | "unclear" {
const hasLie = arcComponents.lie !== null || arcComponents.lieEvidence.length > 0;
const hasTruth = arcComponents.truthAcceptance !== null;
const hasTransformation = arcComponents.transformation !== null;
if (hasLie && hasTruth && hasTransformation) {
return "positive";
}
if (hasLie && !hasTruth) {
// Could be negative or ongoing
return "negative";
}
if (!hasLie && hasTransformation) {
return "flat";
}
return "unclear";
}
function inferRole(
isProtagonist: boolean,
povCount: number,
mentions: number,
arcType: string
): string {
if (isProtagonist) {
return "Protagonist";
}
if (povCount > 5) {
if (arcType === "flat") {
return "Mentor figure";
}
return "Major viewpoint character";
}
if (mentions > 20) {
return "Supporting character";
}
return "Minor character";
}
// === CORE LOGIC ===
function trackCharacters(
segments: SegmentationResult,
bookText: string,
options: {
protagonistName?: string;
maxSecondary?: number;
} = {}
): CharacterTrackingResult {
const maxSecondary = options.maxSecondary || 5;
// Extract all proper nouns and count occurrences
const nameFrequency = extractProperNouns(bookText);
// Build character candidates with POV scoring
const candidates: {
name: string;
mentions: number;
povScore: number;
povScenes: number;
firstAppearance: string;
scenes: Set<string>;
}[] = [];
// Track POV candidates from segmentation
const povCounts = new Map<string, number>();
const firstAppearances = new Map<string, string>();
for (const chapter of segments.chapters) {
for (const scene of chapter.scenes) {
if (scene.povCandidate && scene.povCandidate !== "[First Person]") {
povCounts.set(scene.povCandidate, (povCounts.get(scene.povCandidate) || 0) + 1);
if (!firstAppearances.has(scene.povCandidate)) {
firstAppearances.set(scene.povCandidate, scene.id);
}
}
}
}
// Combine frequency data with POV data
for (const [name, mentions] of nameFrequency.entries()) {
if (mentions < 5) continue; // Skip rarely mentioned names
const povScore = findPovScore(bookText, name);
const povScenes = povCounts.get(name) || 0;
candidates.push({
name,
mentions,
povScore,
povScenes,
firstAppearance: firstAppearances.get(name) || "ch1-s1",
scenes: new Set<string>(),
});
}
// Sort by combined score (POV weighted heavily)
candidates.sort((a, b) => {
const scoreA = a.povScenes * 10 + a.povScore * 2 + a.mentions;
const scoreB = b.povScenes * 10 + b.povScore * 2 + b.mentions;
return scoreB - scoreA;
});
// Override protagonist if specified
if (options.protagonistName) {
const existing = candidates.findIndex(c =>
c.name.toLowerCase() === options.protagonistName!.toLowerCase()
);
if (existing > 0) {
const [protagonist] = candidates.splice(existing, 1);
candidates.unshift(protagonist);
}
}
// Analyze protagonist
let protagonist: CharacterInfo | null = null;
if (candidates.length > 0) {
const protag = candidates[0];
// Find arc components
const arcComponents: ArcComponents = {
lie: null,
lieEvidence: findPatternEvidence(bookText, LIE_PATTERNS, protag.name),
want: null,
need: null,
ghost: null,
ghostScene: null,
truthAcceptance: null,
transformation: null,
};
// Extract specific component summaries
const wantEvidence = findPatternEvidence(bookText, WANT_PATTERNS, protag.name);
if (wantEvidence.length > 0) {
arcComponents.want = wantEvidence[0];
}
const needEvidence = findPatternEvidence(bookText, NEED_PATTERNS, protag.name);
if (needEvidence.length > 0) {
arcComponents.need = needEvidence[0];
}
const ghostEvidence = findPatternEvidence(bookText, GHOST_PATTERNS, protag.name);
if (ghostEvidence.length > 0) {
arcComponents.ghost = ghostEvidence[0];
}
const truthEvidence = findPatternEvidence(bookText, TRUTH_PATTERNS, protag.name);
if (truthEvidence.length > 0) {
arcComponents.truthAcceptance = truthEvidence[0];
}
const transformEvidence = findPatternEvidence(bookText, TRANSFORMATION_PATTERNS, protag.name);
if (transformEvidence.length > 0) {
arcComponents.transformation = transformEvidence[0];
}
if (arcComponents.lieEvidence.length > 0) {
arcComponents.lie = arcComponents.lieEvidence[0];
}
// Find key scenes (scenes with significant arc moments)
const keyScenes: string[] = [];
for (const chapter of segments.chapters) {
for (const scene of chapter.scenes) {
const sceneText = extractSceneText(bookText, scene.startLine, scene.endLine);
const nameMentions = countNameMentions(sceneText, protag.name);
// Check if scene has arc-relevant content
const hasArcContent =
LIE_PATTERNS.some(p => p.test(sceneText) && sceneText.includes(protag.name)) ||
TRUTH_PATTERNS.some(p => p.test(sceneText) && sceneText.includes(protag.name)) ||
TRANSFORMATION_PATTERNS.some(p => p.test(sceneText) && sceneText.includes(protag.name));
if ((scene.povCandidate === protag.name || nameMentions > 5) && hasArcContent) {
keyScenes.push(scene.id);
}
}
}
const arcType = inferArcType(arcComponents);
protagonist = {
name: protag.name,
firstAppearance: protag.firstAppearance,
povScenes: protag.povScenes,
mentions: protag.mentions,
arcType,
arcComponents,
keyScenes: keyScenes.slice(0, 10),
role: "Protagonist",
};
}
// Analyze secondary characters
const secondaryCharacters: CharacterInfo[] = [];
for (let i = 1; i < candidates.length && secondaryCharacters.length < maxSecondary; i++) {
const char = candidates[i];
const arcComponents: ArcComponents = {
lie: null,
lieEvidence: findPatternEvidence(bookText, LIE_PATTERNS, char.name).slice(0, 1),
want: null,
need: null,
ghost: null,
ghostScene: null,
truthAcceptance: null,
transformation: null,
};
if (arcComponents.lieEvidence.length > 0) {
arcComponents.lie = arcComponents.lieEvidence[0];
}
const arcType = inferArcType(arcComponents);
// Find key scenes for this character
const keyScenes: string[] = [];
for (const chapter of segments.chapters) {
for (const scene of chapter.scenes) {
if (scene.povCandidate === char.name) {
keyScenes.push(scene.id);
}
}
}
secondaryCharacters.push({
name: char.name,
firstAppearance: char.firstAppearance,
povScenes: char.povScenes,
mentions: char.mentions,
arcType,
arcComponents,
keyScenes: keyScenes.slice(0, 5),
role: inferRole(false, char.povScenes, char.mentions, arcType),
});
}
// Build character web (relationships based on co-occurrence)
const characterWeb: Record<string, string> = {};
if (protagonist) {
for (const secondary of secondaryCharacters) {
// Simple relationship inference based on context
const pairKey = `${protagonist.name.toLowerCase()}-${secondary.name.toLowerCase()}`;
characterWeb[pairKey] = "relationship detected"; // Would need more sophisticated analysis for actual relationship types
}
}
return {
protagonist,
secondaryCharacters,
characterWeb,
trackedAt: new Date().toISOString(),
};
}
// === CLI ===
function printHelp(): void {
console.log(`Character Tracking Script
Usage:
deno run --allow-read scripts/track-characters.ts <segments.json> <book.txt> [options]
Arguments:
segments.json Output from segment-book.ts
book.txt Original book text file
Options:
--output <file> Write output to JSON file (default: stdout)
--protagonist <name> Specify protagonist name (overrides detection)
--max-secondary <n> Maximum secondary characters to track (default: 5)
--help, -h Show this help message
Examples:
deno run --allow-read track-characters.ts segments.json novel.txt
deno run --allow-read track-characters.ts segments.json novel.txt --output characters.json
deno run --allow-read track-characters.ts segments.json novel.txt --protagonist "Sarah Chen"
`);
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h") || args.length < 2) {
printHelp();
Deno.exit(0);
}
// Parse arguments
let segmentsFile = "";
let bookFile = "";
let outputFile = "";
let protagonistName = "";
let maxSecondary = 5;
const skipIndices = new Set<number>();
for (let i = 0; i < args.length; i++) {
if (skipIndices.has(i)) continue;
if (args[i] === "--output" && args[i + 1]) {
outputFile = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--protagonist" && args[i + 1]) {
protagonistName = args[i + 1];
skipIndices.add(i + 1);
} else if (args[i] === "--max-secondary" && args[i + 1]) {
maxSecondary = parseInt(args[i + 1], 10) || 5;
skipIndices.add(i + 1);
} else if (!args[i].startsWith("--")) {
if (!segmentsFile) {
segmentsFile = args[i];
} else if (!bookFile) {
bookFile = args[i];
}
}
}
if (!segmentsFile || !bookFile) {
console.error("Error: Both segments.json and book.txt are required");
Deno.exit(1);
}
// Read files
let segments: SegmentationResult;
let bookText: string;
try {
const segmentsJson = await Deno.readTextFile(segmentsFile);
segments = JSON.parse(segmentsJson);
} catch (e) {
console.error(`Error reading segments file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
try {
bookText = await Deno.readTextFile(bookFile);
} catch (e) {
console.error(`Error reading book file: ${e instanceof Error ? e.message : e}`);
Deno.exit(1);
}
// Track characters
const result = trackCharacters(segments, bookText, {
protagonistName: protagonistName || undefined,
maxSecondary,
});
// Output
const jsonOutput = JSON.stringify(result, null, 2);
if (outputFile) {
await Deno.writeTextFile(outputFile, jsonOutput);
console.log(`Character tracking complete`);
if (result.protagonist) {
console.log(`Protagonist: ${result.protagonist.name} (${result.protagonist.arcType} arc, ${result.protagonist.povScenes} POV scenes)`);
}
console.log(`Secondary characters: ${result.secondaryCharacters.map(c => c.name).join(", ")}`);
console.log(`Output written to: ${outputFile}`);
} else {
console.log(jsonOutput);
}
}
main();
Related skills
How it compares
Use reverse-outliner on existing prose to extract structure; use forward outlining skills when starting a new manuscript from an idea rather than analyzing a finished draft.
FAQ
What does reverse-outliner produce from a book text file?
reverse-outliner runs a Deno pipeline that writes outline.md plus an analysis folder of intermediate JSON from segmentation, scene batch analysis, and outline synthesis stages, defaulting output under ./reverse-outlines/{book-name}/.
What analysis framework does reverse-outliner apply?
reverse-outliner uses Goal/Conflict/Disaster scene analysis, detects elemental genre and key moments, traces protagonist arcs, and synthesizes markdown outlines at chosen depth before final outline.md generation.
How do you run the reverse-outliner pipeline?
reverse-outliner is executed with deno run --allow-read --allow-write scripts/reverse-outline.ts book.txt, optionally passing --genre and --output flags. Each stage emits JSON artifacts so results can be validated before outline.md is generated.