
Dialogue
- 4 installs
- 8 repo stars
- Updated January 16, 2026
- jwynia/the-kepler-testimonies
Diagnose flat dialogue and develop character voice with subtext and layered meaning.
About
Identifies dialogue problems and guides improvement through voice differentiation and subtext. Use when conversations feel wooden or characters sound alike.
- Three-layer dialogue analysis
- Voice differentiation
Dialogue by the numbers
- 4 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #2,331 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/the-kepler-testimonies --skill dialogueAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 8 |
| Last updated | January 16, 2026 |
| Repository | jwynia/the-kepler-testimonies ↗ |
What it does
Diagnose flat dialogue and develop character voice with subtext and layered meaning.
Files
Dialogue: Diagnostic Skill
You diagnose dialogue-level problems in fiction. Your role is to identify why conversations feel flat and guide writers toward dialogue that does multiple things simultaneously.
Core Principle
Dialogue must do more than one thing at a time or it is too inert for the purposes of fiction. (Sloane, 1979)
Good dialogue simultaneously advances plot, reveals character, builds tension, establishes relationship dynamics, and creates subtext. If dialogue is only delivering information, it's failing.
---
The Three Layers
Every line of dialogue operates on three layers:
| Layer | Definition | Check |
|---|---|---|
| Text | What's literally said | Is it character-specific? Efficient? Natural rhythm? |
| Subtext | What's meant beneath the words | Is there a gap between said and meant? |
| Context | What shapes the exchange | Power dynamics? History? What each character wants? |
When dialogue fails, it usually fails at Layer 2 (no subtext) or Layer 1 (undifferentiated voices).
---
The Dialogue States
State D1: Identical Voices
Symptoms: All characters sound the same. Covering dialogue tags makes speakers indistinguishable. Vocabulary, rhythm, and sentence structure are uniform across characters.
Key Questions:
- Can you identify speakers without tags?
- Does education/background show in speech patterns?
- Do characters have verbal tics, catchphrases, or avoidances?
- Does emotional state affect speech differently per character?
Diagnostic Checklist:
- [ ] Vocabulary range differs between characters
- [ ] Sentence complexity varies by character
- [ ] Directness levels differ (blunt vs. circumlocutory)
- [ ] Each character has something they never say or topics they avoid
Interventions:
- Profile each character's speech patterns separately
- Read dialogue aloud, voice each character distinctly
- Give each character a verbal tic and a verbal avoidance
- Use voice-check tool for quantitative analysis
---
State D2: Wooden Dialogue
Symptoms: Dialogue feels stilted, formal, unnatural. Characters speak in complete grammatical sentences. No contractions. No interruptions. No fragments.
Key Questions:
- Are characters speaking in formal, complete sentences?
- Is the dialogue too clean for the context?
- Are there contractions? Fragments? Interruptions?
- Does it sound natural when read aloud?
Diagnostic Checklist:
- [ ] Characters use contractions appropriately
- [ ] Some sentences are incomplete or interrupted
- [ ] Dialogue has rhythm variation (not metronomic)
- [ ] Characters occasionally talk past each other
Interventions:
- Read every line aloud - if you can't say it naturally, rewrite it
- Let characters interrupt each other
- Cut complete sentences into fragments where natural
- Add verbal stumbles where emotionally appropriate
---
State D3: Exposition Dump
Symptoms: Characters explain things they'd both already know. One character asks questions just so another can explain. "As you know, Bob..." syndrome.
Key Questions:
- Are characters telling each other things they'd already know?
- Is one character functioning as audience stand-in?
- Is information delivery the primary purpose?
- Could this information be discovered rather than explained?
Diagnostic Checklist:
- [ ] No "As you know..." constructions
- [ ] Characters disagree about information (not just relay it)
- [ ] Information emerges from conflict, not lecture
- [ ] Reader discovers alongside character when possible
Interventions:
- Find conflict in the information - let characters disagree
- Have someone discover information on-page
- Break exposition across multiple scenes
- Let characters get facts wrong and be corrected
---
State D4: No Subtext (On-The-Nose)
Symptoms: Characters say exactly what they mean, feel, and want. No gap between surface and meaning. Dialogue lacks dramatic tension because everything is explicit.
Key Questions:
- Are characters stating feelings directly? ("I'm angry")
- Is there a gap between what's said and what's meant?
- Do characters have hidden agendas in conversations?
- What are characters NOT saying that matters?
Diagnostic Checklist:
- [ ] Emotional states shown through behavior, not declared
- [ ] Characters want things they can't ask for directly
- [ ] Body language can contradict words
- [ ] What's unsaid is as important as what's said
Interventions:
- Give each character a hidden agenda for every conversation
- Convert direct statements to indirect expressions (jealousy → comment on someone's "nice corner office")
- Add body language that contradicts or complicates words
- Ask: what would this character never admit out loud?
---
State D5: Single-Function Dialogue
Symptoms: Dialogue accomplishes one thing (usually plot information) but nothing else. Conversations feel functional but inert. No relationship shift, no character revelation, no tension.
The Double-Duty Test: For every exchange, you should be able to answer at least three: 1. What does this accomplish for plot? 2. What does it reveal about character? 3. What is the subtext? 4. How does it affect the relationship?
Diagnostic Checklist:
- [ ] Each conversation advances plot AND reveals character
- [ ] Relationship between speakers shifts during exchange
- [ ] Something changes by end of conversation
- [ ] Scene ends at different emotional point than it began
Interventions:
- Refuse single-function dialogue - always add second purpose
- Track what each character wants vs. what they say they want
- End scenes at changed state, not just information transferred
- Use dialogue-audit tool to check function coverage
---
State D6: Pacing Mismatch
Symptoms: Dialogue pacing doesn't match scene needs. Tense moments have leisurely exchanges. Calm moments have rapid-fire dialogue. No rhythm variation within scenes.
Key Questions:
- Does dialogue speed match emotional intensity?
- Is there rhythm variation within the scene?
- Are action beats and pauses used to control pacing?
- Do important moments get appropriate emphasis?
Pacing Tools:
| Fast Pacing | Slow Pacing |
|---|---|
| Short exchanges | Longer speeches |
| Minimal/no tags | Pauses described |
| No action beats | Action beats between lines |
| Interruptions | Reflection embedded |
Interventions:
- Quicken dialogue as tension rises
- Slow down for emotional weight
- Use silence and pause deliberately
- Vary exchange length within scenes
---
Anti-Patterns
The Exposition Dump
Pattern: "As you know, Bob, our company was founded in 1985 when your father and my uncle..." Problem: Characters explain mutual knowledge for reader benefit Fix: Find conflict in information or discover it on-page
The Identical Twins
Pattern: Every character uses same vocabulary, rhythm, directness Problem: Voices indistinguishable without tags Fix: Profile each character's speech patterns; give distinct verbal DNA
The Court Reporter
Pattern: "Um, hi." "Oh, hey, yeah, so..." "Right, right." Problem: Realistic but dramatically dead - fiction dialogue is compressed reality Fix: Cut to the meaningful; small talk only if it reveals character
The Emotional Narrator
Pattern: "she said angrily," "he replied nervously," "she exclaimed furiously" Problem: Tags doing dialogue's job; telling not showing Fix: Let words and actions carry emotion; use "said"
The Philosopher
Pattern: Characters articulate themes, lessons, or subtext explicitly Problem: Trust removed from reader; preachiness Fix: Trust readers to infer meaning from behavior and implication
The Tennis Match
Pattern: Perfectly alternating, evenly-sized responses, no interruption or power differential Problem: Unnaturally balanced; no one dominates or defers Fix: Let one character dominate, another interrupt, a third stay silent
---
Dialogue Tags
The Stephen King Principle
"Said" is the best dialogue tag to use.
Why "said" works:
- Invisible to readers (doesn't slow reading)
- Lets dialogue do the work
- Avoids "said-bookisms" (murmured, exclaimed, thundered)
When to use other tags:
- Physical action beats (instead of tags entirely)
- Occasionally for genuine necessity (whispered when literal whisper)
- Never to do dialogue's job for it
Tag vs. Beat:
- Tag: "I don't believe you," she said suspiciously.
- Beat: "I don't believe you." She crossed her arms.
The beat shows; the tag tells.
---
Special Situations
Arguments
- Characters talk past each other
- Escalation through repetition
- Old grievances surface suddenly
- Things said that can't be unsaid
Confrontations
- Power dynamics explicit
- Stakes stated or implied
- Threat beneath civility
- Winner and loser emerge
Seduction (any kind)
- Saying one thing, meaning another
- Testing and responding
- Gradual revelation
- What isn't said matters most
Lying
- Character believes what they're saying (their truth)
- Tells consistent with character
- Other characters may or may not detect
- Reader may have privileged information
---
Diagnostic Process
When a writer presents dialogue problems:
1. Identify the Layer
Which layer is failing?
- Text: Undifferentiated voices, wooden delivery
- Subtext: On-the-nose, no hidden agenda
- Context: Unclear power dynamics, missing history
2. Apply the Double-Duty Test
Can the writer answer at least three of: 1. What does this accomplish for plot? 2. What does it reveal about character? 3. What is the subtext? 4. How does it affect the relationship?
3. Read Aloud
The simplest diagnostic: does it sound like something a human would say? Can you distinguish speakers without tags?
4. Check for Anti-Patterns
Run through the anti-pattern list. Most dialogue problems match at least one.
5. Recommend Interventions
Based on identified state, provide specific fixes. Use tools for quantitative analysis when helpful.
---
Available Tools
voice-check.ts
Analyzes dialogue for voice distinctiveness between characters.
deno run --allow-read scripts/voice-check.ts dialogue.txt
deno run --allow-read scripts/voice-check.ts --text "\"I want...\" \"I want...\"" --speakers Alice,BobAnalyzes:
- Vocabulary overlap between speakers
- Average sentence length per speaker
- Contraction usage
- Question/statement ratio
- Interruption patterns
dialogue-audit.ts
Checks dialogue against the double-duty test.
deno run --allow-read scripts/dialogue-audit.ts scene.txt
deno run --allow-read scripts/dialogue-audit.ts --text "dialogue here"Reports:
- Detected functions (plot, character, tension, relationship)
- Subtext indicators
- Tag usage analysis
- Anti-pattern flags
---
Integration with story-sense
| story-sense State | Maps to Dialogue State |
|---|---|
| State 5.5: Dialogue Feels Flat | D1-D5 (diagnose which specifically) |
When to Hand Off
- To character-arc: When voice problems stem from unclear character identity
- To scene-sequencing: When dialogue pacing issues are scene structure issues
- To cliche-transcendence: When dialogue feels predictable (expected responses)
---
Example Interactions
Example 1: Same-Voice Problem
Writer: "My beta readers say all my characters sound the same."
Your approach: 1. Identify state: D1 (Identical Voices) 2. Ask for a sample with 2-3 characters talking 3. Apply the cover-the-tags test 4. Run voice-check tool for quantitative comparison 5. Identify specific differences to add (vocabulary, rhythm, directness) 6. Suggest verbal DNA for each character
Example 2: Flat Conversation
Writer: "This conversation accomplishes what I need but feels dead."
Your approach: 1. Apply Double-Duty Test - how many functions does it serve? 2. If only one (plot), identify state: D5 (Single-Function) 3. Check for subtext (D4) as likely co-occurring problem 4. Ask: what does each character want that they can't say directly? 5. Add hidden agendas and relationship stakes
Example 3: Exposition Problem
Writer: "I need to convey this backstory but it feels like an info dump."
Your approach: 1. Identify state: D3 (Exposition Dump) 2. Ask: can information be discovered instead of explained? 3. Find conflict in the information - who disagrees? 4. Break across multiple scenes if necessary 5. Let characters be wrong and corrected
---
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 or no entry for this skill, ask the user first:
- "Where should I save output from this dialogue session?"
- Suggest:
explorations/dialogue/or a sensible location for this project
4. Store the user's preference:
- In
context/output-config.mdif context network exists - In
.dialogue-output.mdat project root otherwise
Primary Output
For this skill, persist:
- Diagnosed state - which dialogue state(s) apply
- Layer analysis - text, subtext, or context issues identified
- Intervention recommendations - specific techniques to apply
- Character voice notes - distinct voice elements for each character
Conversation vs. File
| Goes to File | Stays in Conversation |
|---|---|
| Dialogue state diagnosis | Clarifying questions |
| Voice distinction notes | Discussion of specific exchanges |
| Subtext recommendations | Writer's experimentation |
| Anti-pattern warnings | Real-time feedback |
File Naming
Pattern: {story}-dialogue-{date}.md Example: novel-chapter3-dialogue-2025-01-15.md
What You Do NOT Do
- You do not write dialogue for writers
- You do not rewrite their lines (show principles, don't execute)
- You do not provide "better versions" of their exchanges
- You do not diagnose prose-level issues beyond dialogue (hand off to prose-style)
- You do not handle plot structure (hand off to story-sense)
Your role is diagnostic: identify the problem, explain why it's a problem, and guide toward the fix. The writer does the writing.
---
Key Insight
Dialogue is compressed reality. It sounds natural but isn't natural - it's carefully constructed to feel spontaneous while doing dramatic work. The goal isn't realism; it's the illusion of realism in service of story.
When dialogue fails, trace it to the layer: Is it the text (how it sounds)? The subtext (what it means)? The context (who's saying it to whom and why)?
Most dialogue problems are subtext problems. Characters saying what they mean is easier to write but dramatically inert. Give every character a hidden agenda. Make them want something they can't ask for. The gap between said and meant is where drama lives.
#!/usr/bin/env -S deno run --allow-read
/**
* Dialogue Audit - Double-Duty and Anti-Pattern Checker
*
* Analyzes dialogue for function coverage (plot, character, subtext, relationship)
* and common anti-patterns (exposition dump, identical twins, etc.).
*
* Usage:
* deno run --allow-read dialogue-audit.ts scene.txt
* deno run --allow-read dialogue-audit.ts --text "dialogue here"
*/
interface DialogueAudit {
lineCount: number;
wordCount: number;
dialogueRatio: number;
// Function detection
functions: {
plotAdvancement: FunctionSignals;
characterReveal: FunctionSignals;
subtextPresent: FunctionSignals;
relationshipDynamics: FunctionSignals;
};
functionScore: number;
// Tag analysis
tagAnalysis: {
saidCount: number;
otherTagCount: number;
actionBeatCount: number;
saidBookisms: string[];
tagHealthy: boolean;
};
// Anti-pattern detection
antiPatterns: AntiPattern[];
// Overall assessment
issues: string[];
recommendations: string[];
}
interface FunctionSignals {
detected: boolean;
confidence: "high" | "medium" | "low";
signals: string[];
}
interface AntiPattern {
name: string;
detected: boolean;
severity: "high" | "medium" | "low";
evidence: string[];
}
// Patterns for detecting dialogue functions
const PLOT_PATTERNS = [
/\b(must|need to|have to|going to|will|plan|decide|discover|find out|learn that)\b/gi,
/\b(happened|killed|stole|escaped|arrived|left|died|born|married|divorced)\b/gi,
/\b(where is|when did|who did|what happened|how did|why did)\b/gi,
/\b(tomorrow|tonight|next|before|after|deadline|time)\b/gi,
];
const CHARACTER_PATTERNS = [
/\b(I always|I never|I believe|I think|I feel|I want|I need|I hate|I love)\b/gi,
/\b(my father|my mother|my family|when I was|I remember|I grew up)\b/gi,
/\b(I'm the kind of|I'm not the type|that's not who I am|that's just how I)\b/gi,
/\b(afraid of|proud of|ashamed of|sorry for|grateful for)\b/gi,
];
const SUBTEXT_INDICATORS = [
// Questions that aren't really questions
/\b(don't you think|wouldn't you say|isn't it|aren't you)\b/gi,
// Deflection and evasion
/\b(I don't know|maybe|perhaps|we'll see|it depends|that's one way)\b/gi,
// Loaded statements
/\b(interesting|fine|whatever|sure|if you say so|I suppose)\b/gi,
// Changing subject
/\b(anyway|but what about|speaking of|by the way|never mind)\b/gi,
];
const RELATIONSHIP_PATTERNS = [
/\b(you always|you never|you used to|remember when we|between us)\b/gi,
/\b(trust|believe|forgive|understand|know me|don't know me)\b/gi,
/\b(we|us|our|together|apart|between|relationship)\b/gi,
/\b(like you|love you|hate you|miss you|need you|want you)\b/gi,
];
// Anti-pattern detection
const EXPOSITION_PATTERNS = [
/\b(as you know|as I'm sure you're aware|you remember|of course you know)\b/gi,
/\b(let me explain|I'll tell you|you see|the thing is|basically)\b/gi,
/\b(founded in|established in|years ago when|the history of)\b/gi,
];
const SAID_BOOKISMS = [
"exclaimed", "declared", "announced", "proclaimed", "stated",
"uttered", "articulated", "vocalized", "verbalized", "intoned",
"opined", "remarked", "observed", "noted", "commented",
"retorted", "countered", "snapped", "barked", "growled",
"hissed", "snarled", "spat", "thundered", "boomed",
"cooed", "purred", "breathed", "sighed", "moaned",
"whimpered", "sobbed", "wailed", "shrieked", "screamed",
"chuckled", "giggled", "laughed", "snickered", "guffawed",
];
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)];
}
function detectFunction(text: string, patterns: RegExp[]): FunctionSignals {
const signals = countMatches(text, patterns);
const count = signals.length;
let confidence: "high" | "medium" | "low";
if (count >= 3) confidence = "high";
else if (count >= 1) confidence = "medium";
else confidence = "low";
return {
detected: count > 0,
confidence,
signals: signals.slice(0, 5),
};
}
function analyzeDialogueTags(text: string): DialogueAudit["tagAnalysis"] {
// Count "said"
const saidMatches = text.match(/\bsaid\b/gi) || [];
// Count other tags (said-bookisms)
const bookismPattern = new RegExp(`\\b(${SAID_BOOKISMS.join("|")})\\b`, "gi");
const bookismMatches = text.match(bookismPattern) || [];
// Count action beats (sentences with dialogue followed by action, not tag)
// Heuristic: dialogue ending with period followed by capital letter action
const actionBeatPattern = /"\s+[A-Z][a-z]+\s+(walked|stood|sat|turned|looked|crossed|nodded|shook|smiled|frowned|leaned|moved|stepped|grabbed|picked|put|set|ran|jumped)/gi;
const actionBeats = text.match(actionBeatPattern) || [];
const foundBookisms = [...new Set(bookismMatches.map(m => m.toLowerCase()))];
return {
saidCount: saidMatches.length,
otherTagCount: bookismMatches.length,
actionBeatCount: actionBeats.length,
saidBookisms: foundBookisms,
tagHealthy: bookismMatches.length <= saidMatches.length,
};
}
function detectAntiPatterns(text: string): AntiPattern[] {
const patterns: AntiPattern[] = [];
// Exposition dump
const expositionMatches = countMatches(text, EXPOSITION_PATTERNS);
patterns.push({
name: "Exposition Dump",
detected: expositionMatches.length > 0,
severity: expositionMatches.length >= 2 ? "high" : "medium",
evidence: expositionMatches,
});
// Emotional Narrator (adverbs in tags)
const emotionalTagPattern = /said\s+(angrily|sadly|happily|nervously|anxiously|fearfully|hopefully|desperately|quietly|loudly|softly|firmly|gently|harshly)/gi;
const emotionalMatches = text.match(emotionalTagPattern) || [];
patterns.push({
name: "Emotional Narrator",
detected: emotionalMatches.length > 0,
severity: emotionalMatches.length >= 3 ? "high" : "medium",
evidence: [...new Set(emotionalMatches)].slice(0, 3),
});
// The Philosopher (explicit theme statements)
const philosopherPattern = /\b(the moral is|what this means|the lesson here|you see|the truth is|life is about|that's what .* is really about)\b/gi;
const philosopherMatches = text.match(philosopherPattern) || [];
patterns.push({
name: "The Philosopher",
detected: philosopherMatches.length > 0,
severity: "medium",
evidence: [...new Set(philosopherMatches)].slice(0, 3),
});
// Court Reporter (too much filler)
const fillerPattern = /\b(um|uh|er|ah|well,|so,|like,|you know,|I mean,)\b/gi;
const fillerMatches = text.match(fillerPattern) || [];
const fillerDensity = fillerMatches.length / (text.split(/\s+/).length || 1);
patterns.push({
name: "Court Reporter",
detected: fillerDensity > 0.05,
severity: fillerDensity > 0.1 ? "high" : "low",
evidence: [...new Set(fillerMatches)].slice(0, 5),
});
// On-the-nose emotions
const onTheNosePattern = /"[^"]*\b(I'm angry|I'm sad|I'm happy|I'm scared|I'm worried|I feel angry|I feel sad|I feel happy)\b[^"]*"/gi;
const onTheNoseMatches = text.match(onTheNosePattern) || [];
patterns.push({
name: "On-The-Nose Emotions",
detected: onTheNoseMatches.length > 0,
severity: onTheNoseMatches.length >= 2 ? "high" : "medium",
evidence: onTheNoseMatches.map(m => m.substring(0, 50) + (m.length > 50 ? "..." : "")),
});
return patterns;
}
function calculateDialogueRatio(text: string): number {
const quotes = text.match(/"[^"]+"/g) || [];
const quotedChars = quotes.join("").length;
return quotedChars / text.length;
}
function auditDialogue(text: string): DialogueAudit {
const words = text.split(/\s+/).filter(w => w.length > 0);
const dialogueContent = (text.match(/"[^"]+"/g) || []).join(" ");
// Detect functions
const functions = {
plotAdvancement: detectFunction(dialogueContent, PLOT_PATTERNS),
characterReveal: detectFunction(dialogueContent, CHARACTER_PATTERNS),
subtextPresent: detectFunction(dialogueContent, SUBTEXT_INDICATORS),
relationshipDynamics: detectFunction(dialogueContent, RELATIONSHIP_PATTERNS),
};
// Calculate function score (0-100)
const detectedCount = Object.values(functions).filter(f => f.detected).length;
const highConfidence = Object.values(functions).filter(f => f.confidence === "high").length;
const functionScore = Math.min(100, (detectedCount * 20) + (highConfidence * 10));
// Analyze tags
const tagAnalysis = analyzeDialogueTags(text);
// Detect anti-patterns
const antiPatterns = detectAntiPatterns(text);
// Generate issues and recommendations
const issues: string[] = [];
const recommendations: string[] = [];
if (detectedCount < 2) {
issues.push("Dialogue may be single-function (fails Double-Duty Test)");
recommendations.push("Add a second purpose: character revelation, relationship shift, or subtext");
}
if (!functions.subtextPresent.detected) {
issues.push("No subtext indicators detected - dialogue may be too on-the-nose");
recommendations.push("Give characters hidden agendas; make them want things they can't ask for directly");
}
if (!tagAnalysis.tagHealthy) {
issues.push("Too many said-bookisms relative to 'said'");
recommendations.push("Replace descriptive tags with action beats or let dialogue carry emotion");
}
for (const pattern of antiPatterns) {
if (pattern.detected && pattern.severity !== "low") {
issues.push(`Anti-pattern detected: ${pattern.name}`);
}
}
if (antiPatterns.find(p => p.name === "Exposition Dump" && p.detected)) {
recommendations.push("Find conflict in the information or have characters discover it on-page");
}
if (antiPatterns.find(p => p.name === "Emotional Narrator" && p.detected)) {
recommendations.push("Remove adverbs from tags; let words and action beats carry emotion");
}
if (antiPatterns.find(p => p.name === "On-The-Nose Emotions" && p.detected)) {
recommendations.push("Convert direct emotional statements to shown behavior or subtext");
}
return {
lineCount: text.split("\n").filter(l => l.trim()).length,
wordCount: words.length,
dialogueRatio: calculateDialogueRatio(text),
functions,
functionScore,
tagAnalysis,
antiPatterns,
issues,
recommendations,
};
}
function formatReport(audit: DialogueAudit): string {
const lines: string[] = [];
lines.push("# Dialogue Audit\n");
lines.push(`Words: ${audit.wordCount} | Dialogue ratio: ${(audit.dialogueRatio * 100).toFixed(1)}%`);
lines.push(`Double-Duty Score: ${audit.functionScore}/100\n`);
lines.push("## Function Detection (Double-Duty Test)\n");
const functionNames = {
plotAdvancement: "Plot Advancement",
characterReveal: "Character Revelation",
subtextPresent: "Subtext Present",
relationshipDynamics: "Relationship Dynamics",
};
for (const [key, label] of Object.entries(functionNames)) {
const func = audit.functions[key as keyof typeof audit.functions];
const status = func.detected ? "+" : "-";
const conf = func.detected ? ` (${func.confidence})` : "";
const signals = func.signals.length > 0 ? `: ${func.signals.slice(0, 3).join(", ")}` : "";
lines.push(` ${status} ${label}${conf}${signals}`);
}
lines.push("");
lines.push("## Tag Analysis\n");
lines.push(` 'said' count: ${audit.tagAnalysis.saidCount}`);
lines.push(` Other tags: ${audit.tagAnalysis.otherTagCount}`);
lines.push(` Action beats: ${audit.tagAnalysis.actionBeatCount}`);
if (audit.tagAnalysis.saidBookisms.length > 0) {
lines.push(` Said-bookisms found: ${audit.tagAnalysis.saidBookisms.join(", ")}`);
}
lines.push(` Tag health: ${audit.tagAnalysis.tagHealthy ? "Good" : "Needs work"}`);
lines.push("");
const detectedPatterns = audit.antiPatterns.filter(p => p.detected);
if (detectedPatterns.length > 0) {
lines.push("## Anti-Patterns Detected\n");
for (const pattern of detectedPatterns) {
lines.push(` - ${pattern.name} (${pattern.severity})`);
if (pattern.evidence.length > 0) {
lines.push(` Evidence: ${pattern.evidence.slice(0, 2).join(", ")}`);
}
}
lines.push("");
}
if (audit.issues.length > 0) {
lines.push("## Issues\n");
for (const issue of audit.issues) {
lines.push(` - ${issue}`);
}
lines.push("");
}
if (audit.recommendations.length > 0) {
lines.push("## Recommendations\n");
for (const rec of audit.recommendations) {
lines.push(` - ${rec}`);
}
lines.push("");
}
if (audit.issues.length === 0) {
lines.push("## Assessment\n");
lines.push(" Dialogue passes basic checks. Verify manually that:");
lines.push(" - Characters sound distinct (run voice-check)");
lines.push(" - Subtext is actually present (not just indicators)");
lines.push(" - Conversation changes something by the end");
lines.push("");
}
return lines.join("\n");
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h")) {
console.log(`Dialogue Audit - Double-Duty and Anti-Pattern Checker
Usage:
deno run --allow-read dialogue-audit.ts <file>
deno run --allow-read dialogue-audit.ts --text "dialogue here"
Options:
--text "..." Provide text inline
--json Output as JSON
--help Show this message
Checks for:
- Function coverage (plot, character, subtext, relationship)
- Tag usage (said vs. said-bookisms)
- Common anti-patterns (exposition dump, emotional narrator, etc.)
`);
Deno.exit(0);
}
const jsonOutput = args.includes("--json");
let text = "";
if (args.includes("--text")) {
const textIndex = args.indexOf("--text");
text = args[textIndex + 1] || "";
} else {
const file = args.find(a => !a.startsWith("--"));
if (file) {
try {
text = await Deno.readTextFile(file);
} catch (e) {
console.error(`Error reading file: ${e}`);
Deno.exit(1);
}
}
}
if (!text.trim()) {
console.error("Error: No text provided. Use --text or provide a file path.");
Deno.exit(1);
}
const audit = auditDialogue(text);
if (jsonOutput) {
console.log(JSON.stringify(audit, null, 2));
} else {
console.log(formatReport(audit));
}
}
main();
#!/usr/bin/env -S deno run --allow-read
/**
* Voice Check - Dialogue Voice Distinctiveness Analyzer
*
* Analyzes dialogue to measure how distinct each speaker's voice is.
* Compares vocabulary, sentence patterns, and speech characteristics.
*
* Usage:
* deno run --allow-read voice-check.ts dialogue.txt
* deno run --allow-read voice-check.ts --text "\"Hello,\" said Alice. \"Hi,\" said Bob."
*/
interface SpeakerStats {
name: string;
lineCount: number;
wordCount: number;
avgWordsPerLine: number;
avgSentenceLength: number;
vocabularySize: number;
contractionRate: number;
questionRate: number;
exclamationRate: number;
fragmentRate: number;
uniqueWords: Set<string>;
topWords: [string, number][];
}
interface VoiceAnalysis {
speakers: Record<string, SpeakerStats>;
overallDistinctiveness: number;
vocabularyOverlap: number;
patternsComparison: PatternComparison[];
issues: string[];
recommendations: string[];
}
interface PatternComparison {
metric: string;
values: Record<string, number>;
variance: number;
distinct: boolean;
}
// Common words to ignore in vocabulary analysis
const STOP_WORDS = new Set([
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
"of", "with", "by", "from", "as", "is", "was", "are", "were", "been",
"be", "have", "has", "had", "do", "does", "did", "will", "would",
"could", "should", "may", "might", "must", "shall", "can", "need",
"it", "its", "this", "that", "these", "those", "i", "you", "he",
"she", "we", "they", "me", "him", "her", "us", "them", "my", "your",
"his", "our", "their", "mine", "yours", "hers", "ours", "theirs",
"what", "which", "who", "whom", "whose", "where", "when", "why", "how",
"all", "each", "every", "both", "few", "more", "most", "other", "some",
"such", "no", "nor", "not", "only", "own", "same", "so", "than", "too",
"very", "just", "also", "now", "here", "there", "then", "once",
]);
function extractDialogue(text: string): Map<string, string[]> {
const speakers = new Map<string, string[]>();
// Pattern: "dialogue" said/asked/replied Speaker or Speaker said "dialogue"
// Also handles: "dialogue," Speaker said.
// Find quoted text with nearby speaker attribution
const patterns = [
// "..." said Name
/"([^"]+)"\s*(?:said|asked|replied|answered|whispered|shouted|muttered|called|cried|exclaimed|declared|announced|continued|added|interrupted|began|finished|concluded)\s+([A-Z][a-z]+)/gi,
// "..." Name said
/"([^"]+)"\s+([A-Z][a-z]+)\s+(?:said|asked|replied|answered|whispered|shouted|muttered|called|cried|exclaimed|declared|announced|continued|added|interrupted|began|finished|concluded)/gi,
// Name said, "..."
/([A-Z][a-z]+)\s+(?:said|asked|replied|answered|whispered|shouted|muttered|called|cried|exclaimed|declared|announced|continued|added|interrupted|began|finished|concluded),?\s+"([^"]+)"/gi,
];
for (const pattern of patterns) {
let match;
const regex = new RegExp(pattern.source, pattern.flags);
while ((match = regex.exec(text)) !== null) {
let speaker: string;
let dialogue: string;
if (match[0].startsWith('"')) {
dialogue = match[1];
speaker = match[2];
} else {
speaker = match[1];
dialogue = match[2];
}
speaker = speaker.trim();
dialogue = dialogue.trim();
if (!speakers.has(speaker)) {
speakers.set(speaker, []);
}
speakers.get(speaker)!.push(dialogue);
}
}
// If no speaker attributions found, try to parse formatted dialogue
// "Speaker: dialogue" format
if (speakers.size === 0) {
const colonPattern = /^([A-Z][A-Za-z]+):\s*"?([^"\n]+)"?/gm;
let match;
while ((match = colonPattern.exec(text)) !== null) {
const speaker = match[1].trim();
const dialogue = match[2].trim();
if (!speakers.has(speaker)) {
speakers.set(speaker, []);
}
speakers.get(speaker)!.push(dialogue);
}
}
return speakers;
}
function analyzeVocabulary(lines: string[]): { unique: Set<string>; top: [string, number][] } {
const wordCounts = new Map<string, number>();
const unique = new Set<string>();
for (const line of lines) {
const words = line.toLowerCase().replace(/[^\w\s']/g, "").split(/\s+/);
for (const word of words) {
if (word.length > 1 && !STOP_WORDS.has(word)) {
unique.add(word);
wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
}
}
}
const sorted = [...wordCounts.entries()].sort((a, b) => b[1] - a[1]);
return { unique, top: sorted.slice(0, 10) };
}
function countContractions(lines: string[]): number {
let total = 0;
let contractions = 0;
const contractionPattern = /\b\w+'\w+\b/g;
for (const line of lines) {
const words = line.split(/\s+/);
total += words.length;
const matches = line.match(contractionPattern);
if (matches) contractions += matches.length;
}
return total > 0 ? contractions / total : 0;
}
function countQuestions(lines: string[]): number {
const questions = lines.filter(l => l.trim().endsWith("?")).length;
return lines.length > 0 ? questions / lines.length : 0;
}
function countExclamations(lines: string[]): number {
const exclamations = lines.filter(l => l.trim().endsWith("!")).length;
return lines.length > 0 ? exclamations / lines.length : 0;
}
function countFragments(lines: string[]): number {
// Simple heuristic: lines without verbs or very short
let fragments = 0;
const verbPatterns = /\b(is|are|was|were|am|be|been|being|have|has|had|do|does|did|will|would|could|should|can|may|might|must|shall|go|goes|went|gone|going|come|comes|came|coming|get|gets|got|getting|make|makes|made|making|know|knows|knew|knowing|think|thinks|thought|thinking|take|takes|took|taking|see|sees|saw|seeing|want|wants|wanted|wanting|say|says|said|saying|tell|tells|told|telling|ask|asks|asked|asking|need|needs|needed|needing|feel|feels|felt|feeling|try|tries|tried|trying|leave|leaves|left|leaving|call|calls|called|calling|keep|keeps|kept|keeping|let|lets|letting|begin|begins|began|begun|beginning|seem|seems|seemed|seeming|help|helps|helped|helping|show|shows|showed|showing|hear|hears|heard|hearing|play|plays|played|playing|run|runs|ran|running|move|moves|moved|moving|live|lives|lived|living|believe|believes|believed|believing|hold|holds|held|holding|bring|brings|brought|bringing|write|writes|wrote|writing|stand|stands|stood|standing|lose|loses|lost|losing|pay|pays|paid|paying|meet|meets|met|meeting|include|includes|included|including|continue|continues|continued|continuing|set|sets|setting|learn|learns|learned|learning|change|changes|changed|changing|lead|leads|led|leading|understand|understands|understood|understanding|watch|watches|watched|watching|follow|follows|followed|following|stop|stops|stopped|stopping|create|creates|created|creating|speak|speaks|spoke|speaking|read|reads|reading|allow|allows|allowed|allowing|add|adds|added|adding|spend|spends|spent|spending|grow|grows|grew|growing|open|opens|opened|opening|walk|walks|walked|walking|win|wins|won|winning|offer|offers|offered|offering|remember|remembers|remembered|remembering|love|loves|loved|loving|consider|considers|considered|considering|appear|appears|appeared|appearing|buy|buys|bought|buying|wait|waits|waited|waiting|serve|serves|served|serving|die|dies|died|dying|send|sends|sent|sending|expect|expects|expected|expecting|build|builds|built|building|stay|stays|stayed|staying|fall|falls|fell|fallen|falling|cut|cuts|cutting|reach|reaches|reached|reaching|kill|kills|killed|killing|remain|remains|remained|remaining)\b/i;
for (const line of lines) {
const wordCount = line.split(/\s+/).length;
if (wordCount <= 3 || !verbPatterns.test(line)) {
fragments++;
}
}
return lines.length > 0 ? fragments / lines.length : 0;
}
function calculateSentenceLength(lines: string[]): number {
let totalWords = 0;
let totalSentences = 0;
for (const line of lines) {
const words = line.split(/\s+/).filter(w => w.length > 0);
totalWords += words.length;
// Count sentence-ending punctuation
const sentences = (line.match(/[.!?]+/g) || []).length || 1;
totalSentences += sentences;
}
return totalSentences > 0 ? totalWords / totalSentences : 0;
}
function analyzeSpeaker(name: string, lines: string[]): SpeakerStats {
const vocab = analyzeVocabulary(lines);
const wordCount = lines.reduce((sum, l) => sum + l.split(/\s+/).length, 0);
return {
name,
lineCount: lines.length,
wordCount,
avgWordsPerLine: lines.length > 0 ? wordCount / lines.length : 0,
avgSentenceLength: calculateSentenceLength(lines),
vocabularySize: vocab.unique.size,
contractionRate: countContractions(lines),
questionRate: countQuestions(lines),
exclamationRate: countExclamations(lines),
fragmentRate: countFragments(lines),
uniqueWords: vocab.unique,
topWords: vocab.top,
};
}
function calculateVariance(values: number[]): number {
if (values.length < 2) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const squaredDiffs = values.map(v => (v - mean) ** 2);
return squaredDiffs.reduce((a, b) => a + b, 0) / values.length;
}
function calculateOverlap(speakers: Map<string, SpeakerStats>): number {
const allWords: Set<string>[] = [];
for (const stats of speakers.values()) {
allWords.push(stats.uniqueWords);
}
if (allWords.length < 2) return 0;
// Calculate pairwise overlap
let totalOverlap = 0;
let pairs = 0;
for (let i = 0; i < allWords.length; i++) {
for (let j = i + 1; j < allWords.length; j++) {
const intersection = new Set([...allWords[i]].filter(w => allWords[j].has(w)));
const union = new Set([...allWords[i], ...allWords[j]]);
totalOverlap += intersection.size / union.size;
pairs++;
}
}
return pairs > 0 ? totalOverlap / pairs : 0;
}
function analyzeVoices(speakerData: Map<string, string[]>): VoiceAnalysis {
const speakers: Record<string, SpeakerStats> = {};
const speakerStats = new Map<string, SpeakerStats>();
for (const [name, lines] of speakerData) {
const stats = analyzeSpeaker(name, lines);
speakers[name] = stats;
speakerStats.set(name, stats);
}
// Compare patterns
const patterns: PatternComparison[] = [];
const speakerNames = [...speakerStats.keys()];
if (speakerNames.length >= 2) {
const metrics = [
{ name: "avgWordsPerLine", label: "Words per line" },
{ name: "avgSentenceLength", label: "Sentence length" },
{ name: "contractionRate", label: "Contraction usage" },
{ name: "questionRate", label: "Question frequency" },
{ name: "exclamationRate", label: "Exclamation frequency" },
{ name: "fragmentRate", label: "Fragment frequency" },
];
for (const metric of metrics) {
const values: Record<string, number> = {};
const nums: number[] = [];
for (const name of speakerNames) {
const val = speakerStats.get(name)![metric.name as keyof SpeakerStats] as number;
values[name] = val;
nums.push(val);
}
const variance = calculateVariance(nums);
patterns.push({
metric: metric.label,
values,
variance,
distinct: variance > 0.01, // Threshold for "distinct enough"
});
}
}
const vocabularyOverlap = calculateOverlap(speakerStats);
// Calculate overall distinctiveness (0-100)
const distinctPatterns = patterns.filter(p => p.distinct).length;
const patternScore = (distinctPatterns / Math.max(patterns.length, 1)) * 50;
const overlapScore = (1 - vocabularyOverlap) * 50;
const overallDistinctiveness = Math.round(patternScore + overlapScore);
// Generate issues and recommendations
const issues: string[] = [];
const recommendations: string[] = [];
if (vocabularyOverlap > 0.6) {
issues.push("High vocabulary overlap between speakers");
recommendations.push("Give each character domain-specific vocabulary or verbal tics");
}
if (patterns.filter(p => !p.distinct).length > patterns.length / 2) {
issues.push("Speech patterns too similar across speakers");
recommendations.push("Vary sentence length, directness, or formality between characters");
}
const lowContraction = [...speakerStats.values()].filter(s => s.contractionRate < 0.05);
if (lowContraction.length === speakerStats.size && speakerStats.size > 0) {
issues.push("No speakers use contractions - may feel formal/wooden");
recommendations.push("Add contractions for more natural speech patterns");
}
const allQuestions = [...speakerStats.values()].filter(s => s.questionRate > 0.5);
if (allQuestions.length > speakerStats.size / 2) {
issues.push("Most speakers ask many questions - may indicate exposition dump pattern");
}
if (speakerStats.size < 2) {
issues.push("Need at least two speakers to compare voice distinctiveness");
}
return {
speakers,
overallDistinctiveness,
vocabularyOverlap,
patternsComparison: patterns,
issues,
recommendations,
};
}
function formatReport(analysis: VoiceAnalysis): string {
const lines: string[] = [];
lines.push("# Voice Distinctiveness Analysis\n");
lines.push(`Overall distinctiveness: ${analysis.overallDistinctiveness}/100`);
lines.push(`Vocabulary overlap: ${(analysis.vocabularyOverlap * 100).toFixed(1)}%\n`);
lines.push("## Speaker Profiles\n");
for (const [name, stats] of Object.entries(analysis.speakers)) {
lines.push(`### ${name}`);
lines.push(` Lines: ${stats.lineCount} | Words: ${stats.wordCount}`);
lines.push(` Avg words/line: ${stats.avgWordsPerLine.toFixed(1)}`);
lines.push(` Contractions: ${(stats.contractionRate * 100).toFixed(1)}%`);
lines.push(` Questions: ${(stats.questionRate * 100).toFixed(1)}%`);
lines.push(` Fragments: ${(stats.fragmentRate * 100).toFixed(1)}%`);
if (stats.topWords.length > 0) {
lines.push(` Top words: ${stats.topWords.slice(0, 5).map(([w]) => w).join(", ")}`);
}
lines.push("");
}
if (analysis.patternsComparison.length > 0) {
lines.push("## Pattern Comparison\n");
for (const pattern of analysis.patternsComparison) {
const status = pattern.distinct ? "+" : "-";
const values = Object.entries(pattern.values)
.map(([name, val]) => `${name}: ${typeof val === 'number' ? val.toFixed(2) : val}`)
.join(", ");
lines.push(` ${status} ${pattern.metric}: ${values}`);
}
lines.push("");
}
if (analysis.issues.length > 0) {
lines.push("## Issues\n");
for (const issue of analysis.issues) {
lines.push(` - ${issue}`);
}
lines.push("");
}
if (analysis.recommendations.length > 0) {
lines.push("## Recommendations\n");
for (const rec of analysis.recommendations) {
lines.push(` - ${rec}`);
}
lines.push("");
}
return lines.join("\n");
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h")) {
console.log(`Voice Check - Dialogue Voice Distinctiveness Analyzer
Usage:
deno run --allow-read voice-check.ts <file>
deno run --allow-read voice-check.ts --text "dialogue here"
Options:
--text "..." Provide dialogue inline
--json Output as JSON
--help Show this message
Expected formats:
- "Dialogue," said Speaker.
- "Dialogue," Speaker said.
- Speaker said, "Dialogue."
- SPEAKER: Dialogue
The tool compares vocabulary, sentence patterns, and speech characteristics
to measure how distinct each speaker's voice is.
`);
Deno.exit(0);
}
const jsonOutput = args.includes("--json");
let text = "";
if (args.includes("--text")) {
const textIndex = args.indexOf("--text");
text = args[textIndex + 1] || "";
} else {
const file = args.find(a => !a.startsWith("--"));
if (file) {
try {
text = await Deno.readTextFile(file);
} catch (e) {
console.error(`Error reading file: ${e}`);
Deno.exit(1);
}
}
}
if (!text.trim()) {
console.error("Error: No text provided. Use --text or provide a file path.");
Deno.exit(1);
}
const speakerData = extractDialogue(text);
if (speakerData.size === 0) {
console.error("Error: Could not extract dialogue. Check format (see --help).");
Deno.exit(1);
}
const analysis = analyzeVoices(speakerData);
if (jsonOutput) {
// Convert Sets to arrays for JSON
const jsonSafe = {
...analysis,
speakers: Object.fromEntries(
Object.entries(analysis.speakers).map(([k, v]) => [
k,
{ ...v, uniqueWords: [...v.uniqueWords] },
])
),
};
console.log(JSON.stringify(jsonSafe, null, 2));
} else {
console.log(formatReport(analysis));
}
}
main();