calliope-ts 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.opencode/skills/calliope-ts/SKILL.md +74 -0
  3. package/LICENSE +201 -0
  4. package/README.md +485 -0
  5. package/dist/depfix.d.ts +13 -0
  6. package/dist/depfix.d.ts.map +1 -0
  7. package/dist/depfix.js +84 -0
  8. package/dist/display.d.ts +38 -0
  9. package/dist/display.d.ts.map +1 -0
  10. package/dist/display.js +890 -0
  11. package/dist/index.d.ts +50 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +504 -0
  14. package/dist/parser.d.ts +10 -0
  15. package/dist/parser.d.ts.map +1 -0
  16. package/dist/parser.js +688 -0
  17. package/dist/phonological.d.ts +41 -0
  18. package/dist/phonological.d.ts.map +1 -0
  19. package/dist/phonological.js +788 -0
  20. package/dist/rhyme.d.ts +26 -0
  21. package/dist/rhyme.d.ts.map +1 -0
  22. package/dist/rhyme.js +340 -0
  23. package/dist/scandroid.d.ts +17 -0
  24. package/dist/scandroid.d.ts.map +1 -0
  25. package/dist/scandroid.js +435 -0
  26. package/dist/scansion.d.ts +37 -0
  27. package/dist/scansion.d.ts.map +1 -0
  28. package/dist/scansion.js +1007 -0
  29. package/dist/scansion_debug.js +586 -0
  30. package/dist/stress.d.ts +32 -0
  31. package/dist/stress.d.ts.map +1 -0
  32. package/dist/stress.js +1372 -0
  33. package/dist/tagfix.d.ts +6 -0
  34. package/dist/tagfix.d.ts.map +1 -0
  35. package/dist/tagfix.js +101 -0
  36. package/dist/types.d.ts +173 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +4 -0
  39. package/package.json +62 -0
  40. package/src/depfix.ts +88 -0
  41. package/src/display.ts +954 -0
  42. package/src/index.ts +541 -0
  43. package/src/parser.ts +837 -0
  44. package/src/phonological.ts +849 -0
  45. package/src/rhyme.ts +328 -0
  46. package/src/scandroid.ts +434 -0
  47. package/src/scansion.ts +1053 -0
  48. package/src/stress.ts +1381 -0
  49. package/src/tagfix.ts +104 -0
  50. package/src/types.ts +230 -0
package/src/index.ts ADDED
@@ -0,0 +1,541 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from 'fs';
3
+ import * as readline from 'readline';
4
+ import chalk from 'chalk';
5
+ import { fileURLToPath } from 'url';
6
+ import { parseDocument } from './parser.js';
7
+ import {
8
+ assignLexicalStress,
9
+ applyCompoundStress,
10
+ applyNuclearStress,
11
+ assignRelativeStresses,
12
+ } from './stress.js';
13
+ import {
14
+ buildPhonologicalHierarchy,
15
+ renderHierarchy,
16
+ renderKeyStresses,
17
+ flattenDisplayEntries,
18
+ } from './phonological.js';
19
+ import { extractKeyStresses, scoreMeters, applyStanzaConsensus, applyRhythmLayer } from './scansion.js';
20
+ import { applyRhymeAndForm } from './rhyme.js';
21
+ import {
22
+ scandroidCorralWeird,
23
+ scandroidMaximizeNormal,
24
+ stressToMarks,
25
+ } from './scandroid.js';
26
+ import {
27
+ renderUnifiedDisplay,
28
+ renderFullLegend,
29
+ renderReadingView,
30
+ type ReadingStanza,
31
+ } from './display.js';
32
+ import type {
33
+ ClsSentence,
34
+ MetreName,
35
+ IntonationalUnit,
36
+ StressLevel,
37
+ ScansionResult,
38
+ LineResult,
39
+ PhonologicalScansionDetail,
40
+ SyllableDisplayEntry,
41
+ FootDisplayEntry,
42
+ FormattedDisplay,
43
+ DisplayOptions,
44
+ } from './types.js';
45
+
46
+ /**
47
+ * Scan one VERSE LINE (which may parse into several grammatical sentences).
48
+ *
49
+ * The line — not the sentence — is the metrical domain (McAleese; Kiparsky's
50
+ * "phonological phrasing determines the location of caesurae in verse"). A
51
+ * line like "You'll slurp potato soup. No straws! Suck gauze." is ONE iambic
52
+ * pentameter with internal intonational breaks, not three fragments each
53
+ * carrying its own meter. So the linguistic passes (lexical stress, phrasal
54
+ * hierarchy, compound/nuclear/relative stress) run per sentence — those rules
55
+ * are intra-sentence by nature — but the metrical fit runs once over the
56
+ * line's full concatenated syllable stream, with each sentence's IUs preserved
57
+ * as IU boundaries (→ hard caesurae) inside the line.
58
+ */
59
+ function processLine(sents: ClsSentence[]): LineResult | null {
60
+ if (sents.length === 0) return null;
61
+
62
+ const iusPerSent: IntonationalUnit[][] = [];
63
+ for (const sent of sents) {
64
+ assignLexicalStress(sent.words);
65
+ const sentIus = buildPhonologicalHierarchy(sent);
66
+ applyCompoundStress(sentIus);
67
+ applyNuclearStress(sentIus);
68
+ assignRelativeStresses(sent.words, sentIus);
69
+ iusPerSent.push(sentIus);
70
+ }
71
+
72
+ // Merge the sentences' streams into the line-level scansion domain.
73
+ const words = sents.flatMap(s => s.words);
74
+ const ius = iusPerSent.flat();
75
+ let merged: ClsSentence;
76
+ if (sents.length === 1) {
77
+ merged = sents[0];
78
+ } else {
79
+ // Re-index sequentially so per-sentence 1-based indices don't collide in
80
+ // any downstream order-by-index logic. (All hierarchy/dependency passes
81
+ // above are already complete and reference words by object identity.)
82
+ words.forEach((w, i) => { w.index = i + 1; });
83
+ merged = {
84
+ index: sents[0].index,
85
+ nodes: null,
86
+ dependencies: sents.flatMap(s => s.dependencies),
87
+ words,
88
+ xml: '',
89
+ };
90
+ }
91
+
92
+ const keyStresses = extractKeyStresses(ius, words);
93
+
94
+ // Full phonological scansion over the whole line.
95
+ const phonoDetail = scoreMeters(keyStresses, words, ius);
96
+ phonoDetail.all = renderHierarchy(ius, words);
97
+ phonoDetail.keyStresses = renderKeyStresses(ius, words, keyStresses);
98
+
99
+ // Scandroid – use the actual foot count from the phonological detection
100
+ const stressPattern: StressLevel[] = words.flatMap((w) =>
101
+ w.syllables.map((s) => s.relativeStress ?? 'w')
102
+ );
103
+ const marks = stressToMarks(stressPattern);
104
+ const actualFeet = phonoDetail.footCount > 0 ? phonoDetail.footCount : 5; // fallback only if unknown
105
+ const corral = scandroidCorralWeird(marks, actualFeet);
106
+ const max = scandroidMaximizeNormal(marks, actualFeet);
107
+
108
+ const corralResult: ScansionResult | undefined = corral.footlist.length
109
+ ? {
110
+ meter: 'iambic',
111
+ scansion: corral.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
112
+ certainty: 0,
113
+ weightScore: 0,
114
+ maxPossibleWeight: 0,
115
+ algorithm: 'Scandroid Corral the Weird',
116
+ }
117
+ : undefined;
118
+ const maxResult: ScansionResult | undefined = max.footlist.length
119
+ ? {
120
+ meter: 'iambic',
121
+ scansion: max.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
122
+ certainty: 0,
123
+ weightScore: 0,
124
+ maxPossibleWeight: 0,
125
+ algorithm: 'Scandroid Maximise the Normal',
126
+ }
127
+ : undefined;
128
+
129
+ return {
130
+ sentence: merged,
131
+ phonologicalHierarchy: ius,
132
+ keyStresses,
133
+ phonologicalScansion: phonoDetail,
134
+ scandroidCorral: corralResult,
135
+ scandroidMaximise: maxResult,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Analyse a multi‑line text with stanza awareness.
141
+ * Returns a list of stanza arrays, each containing the per‑line results.
142
+ */
143
+
144
+ /**
145
+ * Continuity rename (maintainer directive 2026-06-14): a line whose standalone
146
+ * meter merely edges out the stanza/poem-dominant meter (consensusMeter set by
147
+ * applyStanzaConsensus) ADOPTS the dominant meter as its base reading — the
148
+ * scansion, foot count, and certainty are re-fitted under that meter — and the
149
+ * numerically-best standalone meter is kept as a concise note
150
+ * (`standaloneMeter`). Metrical continuity outranks a hair of fit score.
151
+ */
152
+ function applyContinuityRename(results: LineResult[]): void {
153
+ // A stanza-level rhythm verdict (set on at least half the lines) means the
154
+ // group reads as accentual/dolnik/taktovik — classical continuity renaming
155
+ // does not apply there.
156
+ const noted = results.filter(r => r.phonologicalScansion.rhythmNote).length;
157
+ if (results.length > 0 && noted >= results.length / 2) return;
158
+ for (const res of results) {
159
+ const d = res.phonologicalScansion;
160
+ if (!d.consensusMeter) continue;
161
+ const family = d.consensusMeter.split(' ')[0] as MetreName;
162
+ const forced = scoreMeters(res.keyStresses, res.sentence.words, res.phonologicalHierarchy, family);
163
+ if (!forced || forced.meterName === 'free verse' || forced.footCount <= 0) continue;
164
+ d.standaloneMeter = d.meter;
165
+ d.meter = forced.meter;
166
+ d.meterName = forced.meterName;
167
+ d.footCount = forced.footCount;
168
+ d.scansion = forced.scansion;
169
+ d.certainty = forced.certainty;
170
+ d.summary = forced.summary;
171
+ d.consensusMeter = undefined;
172
+ }
173
+ }
174
+
175
+ export function analyzeStanzas(text: string, useScandroid = true): LineResult[][] {
176
+ const stanzas = text.split(/\n\s*\n/);
177
+ const results: LineResult[][] = [];
178
+ for (const stanza of stanzas) {
179
+ const lines = stanza.split('\n').filter(l => l.trim() !== '');
180
+ const stanzaResults: LineResult[] = [];
181
+ for (const line of lines) {
182
+ const doc = parseDocument(line);
183
+ const res = processLine(doc.sentences);
184
+ if (res) stanzaResults.push(res);
185
+ }
186
+ // Resolve near-tie lines toward the stanza's dominant meter (explicit, non-
187
+ // destructive: annotates phonologicalScansion.consensusMeter), then classify
188
+ // non-classical rhythm (dolnik/taktovik/accentual → rhythmNote; ballad is a
189
+ // FORM verdict and belongs to the rhyme-aware form layer).
190
+ applyStanzaConsensus(stanzaResults.map(r => r.phonologicalScansion));
191
+ // Classical-vs-accentual is decided FIRST (rhythm layer); continuity
192
+ // renaming applies only where no stanza-level accentual/dolnik verdict
193
+ // fired — otherwise the rename would snowball weak scattered classical
194
+ // readings into false dominance (Wyatt lost its "4-beat accentual").
195
+ applyRhythmLayer(stanzaResults.map(r => r.phonologicalScansion));
196
+ applyContinuityRename(stanzaResults);
197
+ results.push(stanzaResults);
198
+ }
199
+ // Poem-scale continuity: lines left un-renamed (their own stanza had no
200
+ // unique dominant) get a second chance against the poem-wide dominant.
201
+ if (results.length > 1) {
202
+ const all = results.flat();
203
+ applyStanzaConsensus(all.map(r => r.phonologicalScansion));
204
+ applyContinuityRename(all);
205
+ for (const st of results) applyRhythmLayer(st.map(r => r.phonologicalScansion));
206
+ }
207
+ // Rhyme scheme + poetic-form identification spans stanzas (sonnets, terza
208
+ // rima); annotation-only (rhyme/formNote on each line's detail).
209
+ applyRhymeAndForm(results);
210
+ return results;
211
+ }
212
+
213
+ /**
214
+ * Convenience wrapper: analyse a multi‑line text, ignore stanza breaks,
215
+ * return a flat list of LineResult for each line.
216
+ */
217
+ export function analyzeText(text: string, useScandroid = true): LineResult[] {
218
+ const stanzaResults = analyzeStanzas(text, useScandroid);
219
+ return stanzaResults.flat();
220
+ }
221
+
222
+ /**
223
+ * Analyse a document for the reading view: like analyzeStanzas, but retains
224
+ * each original input line alongside its (1+) parsed sentence results so the
225
+ * stress gradient can be projected back over the verbatim text.
226
+ */
227
+ export function analyzeReadingDocument(text: string): ReadingStanza[] {
228
+ const stanzas = text.split(/\n\s*\n/);
229
+ const out: ReadingStanza[] = [];
230
+ for (const stanza of stanzas) {
231
+ const rawLines = stanza.split('\n').filter(l => l.trim() !== '');
232
+ if (rawLines.length === 0) continue;
233
+ const lines = rawLines.map(raw => {
234
+ const doc = parseDocument(raw);
235
+ const res = processLine(doc.sentences);
236
+ return { raw, results: res ? [res] : [] };
237
+ });
238
+ applyStanzaConsensus(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
239
+ applyRhythmLayer(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
240
+ applyContinuityRename(lines.flatMap(l => l.results));
241
+ out.push({ lines });
242
+ }
243
+ if (out.length > 1) {
244
+ const all = out.flatMap(st => st.lines.flatMap(l => l.results));
245
+ applyStanzaConsensus(all.map(r => r.phonologicalScansion));
246
+ applyContinuityRename(all);
247
+ for (const st of out) applyRhythmLayer(st.lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
248
+ }
249
+ applyRhymeAndForm(out.map(st => st.lines.flatMap(l => l.results)));
250
+ return out;
251
+ }
252
+
253
+ // ─── CLI HELPERS ────────────────────────────────────────────────
254
+
255
+ function showResults(text: string): void {
256
+ // Use the reading-document analysis so each LineResult keeps its verbatim input
257
+ // line (for the tail Reading Projection) and the stanza-consensus annotation.
258
+ const stanzas = analyzeReadingDocument(text);
259
+
260
+ for (let s = 0; s < stanzas.length; s++) {
261
+ if (stanzas.length > 1) {
262
+ console.log('\n' + chalk.bold('═══ Stanza ' + (s + 1) + ' ═══'));
263
+ }
264
+ for (const ln of stanzas[s].lines) {
265
+ for (const res of ln.results) {
266
+ console.log(renderUnifiedDisplay(res, ln.raw));
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ function showReadingView(text: string): void {
273
+ const stanzas = analyzeReadingDocument(text);
274
+ console.log(renderReadingView(stanzas));
275
+ }
276
+
277
+ // ─── MULTI-LINE INPUT (paste-friendly) ──────────────────────────
278
+ // Goal: the user pastes a whole poem (stanza breaks and all) and presses Enter
279
+ // ONCE to scan it; Esc returns to the menu. The trick is distinguishing a blank
280
+ // line that is a *stanza break* (part of the pasted burst) from a blank line that
281
+ // means *"I'm done"* (a deliberate, later keystroke). A paste streams in as one
282
+ // rapid burst (sub-ms between lines); a human Enter comes after a real pause. So
283
+ // once a burst has been seen, the next Enter that arrives after an idle gap
284
+ // submits — flushing the current line whether or not the paste ended in a newline.
285
+ // The pure decision below is unit-tested (a TTY can't be driven from CI).
286
+
287
+ export const ML_IDLE_MS = 120;
288
+
289
+ export type MLEvent =
290
+ | { kind: 'char'; str: string; gap: number }
291
+ | { kind: 'return'; gap: number }
292
+ | { kind: 'backspace'; gap: number }
293
+ | { kind: 'escape' }
294
+ | { kind: 'eof' };
295
+
296
+ export interface MLState { lines: string[]; cur: string; sawBurst: boolean; }
297
+ export type MLResult = 'continue' | 'submit' | 'cancel';
298
+
299
+ export function newMLState(): MLState { return { lines: [], cur: '', sawBurst: false }; }
300
+
301
+ function mlHasContent(st: MLState): boolean {
302
+ return st.cur.trim() !== '' || st.lines.some(l => l.trim() !== '');
303
+ }
304
+
305
+ /**
306
+ * Fold one input event into the multi-line buffer, returning whether to keep
307
+ * reading ('continue'), scan the buffer ('submit'), or abandon it ('cancel').
308
+ * • A burst-speed Enter (gap < ML_IDLE_MS) is always a line break — so pasted
309
+ * stanza-break blank lines are preserved.
310
+ * • After a burst, the first idle Enter submits (flushing any pending line).
311
+ * • With no burst (slow hand-typing), a non-empty line + Enter is a line break and
312
+ * a blank line submits — the conventional "blank line to finish".
313
+ * • Esc cancels (→ menu); Ctrl-D submits whatever is there.
314
+ */
315
+ export function feedMultilineEvent(st: MLState, ev: MLEvent): MLResult {
316
+ switch (ev.kind) {
317
+ case 'escape':
318
+ return 'cancel';
319
+ case 'eof':
320
+ if (st.cur.length) { st.lines.push(st.cur); st.cur = ''; }
321
+ return mlHasContent(st) ? 'submit' : 'cancel';
322
+ case 'backspace':
323
+ if (st.cur.length) st.cur = st.cur.slice(0, -1);
324
+ return 'continue';
325
+ case 'char': {
326
+ if (ev.gap < ML_IDLE_MS) st.sawBurst = true;
327
+ // A pasted chunk may arrive with embedded newlines in one event.
328
+ const parts = ev.str.split(/\r\n|\r|\n/);
329
+ for (let i = 0; i < parts.length; i++) {
330
+ if (i > 0) { st.lines.push(st.cur); st.cur = ''; }
331
+ st.cur += parts[i];
332
+ }
333
+ return 'continue';
334
+ }
335
+ case 'return': {
336
+ if (ev.gap < ML_IDLE_MS) { // burst-speed → a line break (keep blanks)
337
+ st.sawBurst = true;
338
+ st.lines.push(st.cur); st.cur = '';
339
+ return 'continue';
340
+ }
341
+ // Deliberate (idle) Enter.
342
+ if (st.sawBurst || st.cur.trim() === '') {
343
+ if (st.cur.length) { st.lines.push(st.cur); st.cur = ''; }
344
+ return mlHasContent(st) ? 'submit' : 'continue';
345
+ }
346
+ // Slow hand-typing of a fresh non-empty line → just a line break.
347
+ st.lines.push(st.cur); st.cur = '';
348
+ return 'continue';
349
+ }
350
+ }
351
+ }
352
+
353
+ /** Strip trailing blank lines (e.g. a paste's trailing newline) from a buffer. */
354
+ function trimTrailingBlanks(lines: string[]): string[] {
355
+ const out = lines.slice();
356
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
357
+ return out;
358
+ }
359
+
360
+ /**
361
+ * Read a pasteable multi-line block from the TTY in raw mode (so Esc and the
362
+ * paste-burst timing are observable). Resolves to the lines, or null if the user
363
+ * pressed Esc (→ return to menu).
364
+ */
365
+ async function readPastableBlock(): Promise<string[] | null> {
366
+ const stdin = process.stdin;
367
+ return new Promise<string[] | null>((resolve) => {
368
+ const st = newMLState();
369
+ let lastTime = Date.now();
370
+ readline.emitKeypressEvents(stdin);
371
+ const wasRaw = !!(stdin as any).isRaw;
372
+ if (stdin.isTTY) stdin.setRawMode(true);
373
+ stdin.resume();
374
+
375
+ const onKey = (str: string | undefined, key: any) => {
376
+ const now = Date.now();
377
+ const gap = now - lastTime;
378
+ lastTime = now;
379
+ key = key || {};
380
+ if (key.ctrl && key.name === 'c') { // Ctrl-C → quit
381
+ cleanup(); process.stdout.write('\n'); process.exit(0);
382
+ }
383
+ let ev: MLEvent | null = null;
384
+ if (key.name === 'escape') ev = { kind: 'escape' };
385
+ else if (key.ctrl && key.name === 'd') ev = { kind: 'eof' };
386
+ else if (key.name === 'return' || key.name === 'enter') ev = { kind: 'return', gap };
387
+ else if (key.name === 'backspace') ev = { kind: 'backspace', gap };
388
+ else if (str && !key.ctrl && !key.meta) ev = { kind: 'char', str, gap };
389
+ if (!ev) return; // ignore arrows / fn keys
390
+ // Echo (raw mode does not echo for us).
391
+ if (ev.kind === 'char') process.stdout.write(str!);
392
+ else if (ev.kind === 'return') process.stdout.write('\n');
393
+ else if (ev.kind === 'backspace' && st.cur.length) process.stdout.write('\b \b');
394
+
395
+ const result = feedMultilineEvent(st, ev);
396
+ if (result === 'submit') { cleanup(); process.stdout.write('\n'); resolve(st.lines); }
397
+ else if (result === 'cancel') { cleanup(); process.stdout.write('\n'); resolve(null); }
398
+ };
399
+
400
+ function cleanup() {
401
+ stdin.removeListener('keypress', onKey);
402
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
403
+ stdin.pause();
404
+ }
405
+ stdin.on('keypress', onKey);
406
+ });
407
+ }
408
+
409
+ async function replMode(): Promise<void> {
410
+ const prompts = (await import('prompts')).default;
411
+
412
+ console.log('');
413
+ console.log(chalk.bold(' CALLIOPE_TS — Phonological Poetry Scansion (CLI) '));
414
+ console.log(chalk.dim('• Multi-Step Syntactic, Phonological, & Prosodic Analysis •'));
415
+ console.log('');
416
+
417
+ let running = true;
418
+ while (running) {
419
+ const response = await prompts({
420
+ type: 'select',
421
+ name: 'action',
422
+ message: 'Choose an action:',
423
+ choices: [
424
+ { title: 'Parse & Scan (multi-line reading view)', value: 'reading-multi' },
425
+ { title: 'Single Line Analysis (detailed view)', value: 'scan' },
426
+ { title: 'Line-by-Line Analysis (detailed view)', value: 'multiline' },
427
+ { title: 'Parse & Scan from File (reading view)', value: 'reading-file' },
428
+ { title: 'Analyze from File (detailed view)', value: 'file' },
429
+ { title: 'Display Legend', value: 'legend' },
430
+ { title: 'Exit', value: 'exit' },
431
+ ],
432
+ });
433
+
434
+ if (!response.action || response.action === 'exit') {
435
+ running = false;
436
+ console.log(chalk.dim('\nGoodbye.\n'));
437
+ break;
438
+ }
439
+
440
+ if (response.action === 'legend') {
441
+ console.log('\n' + renderFullLegend() + '\n');
442
+ continue;
443
+ }
444
+
445
+ if (response.action === 'scan') {
446
+ const lineResponse = await prompts({
447
+ type: 'text',
448
+ name: 'line',
449
+ message: 'Enter a line of verse:',
450
+ });
451
+ if (lineResponse.line && lineResponse.line.trim()) {
452
+ try {
453
+ showResults(lineResponse.line.trim());
454
+ } catch (err) {
455
+ console.error(chalk.red('Error during scansion:'), err);
456
+ }
457
+ }
458
+ continue;
459
+ }
460
+
461
+ if (response.action === 'file' || response.action === 'reading-file') {
462
+ const render = response.action === 'reading-file' ? showReadingView : showResults;
463
+ const fileResponse = await prompts({
464
+ type: 'text',
465
+ name: 'path',
466
+ message: 'Enter file path:',
467
+ });
468
+ if (fileResponse.path && fileResponse.path.trim()) {
469
+ try {
470
+ const text = fs.readFileSync(fileResponse.path.trim(), 'utf-8');
471
+ render(text);
472
+ } catch (err) {
473
+ console.error(chalk.red('Error reading file:'), err);
474
+ }
475
+ }
476
+ continue;
477
+ }
478
+
479
+ if (response.action === 'multiline' || response.action === 'reading-multi') {
480
+ const render = response.action === 'reading-multi' ? showReadingView : showResults;
481
+ console.log(chalk.dim('Paste your poem and press Enter to scan it. (Esc to cancel)'));
482
+ const block = await readPastableBlock();
483
+ if (block === null) continue; // Esc → back to the menu
484
+ const lines = trimTrailingBlanks(block);
485
+ if (lines.length > 0) {
486
+ try {
487
+ render(lines.join('\n'));
488
+ } catch (err) {
489
+ console.error(chalk.red('Error during scansion:'), err);
490
+ }
491
+ }
492
+ continue;
493
+ }
494
+ }
495
+ }
496
+
497
+ // ─── CLI ENTRY POINT ─────────────────────────────────────────────
498
+
499
+ async function main(): Promise<void> {
500
+ let rawArgs = process.argv.slice(2);
501
+ // --reading / -r : emit the compact reading view (poem in original formatting,
502
+ // syllables stress-coloured, + per-line stress maps) instead of the full dump.
503
+ const reading = rawArgs.includes('--reading') || rawArgs.includes('-r');
504
+ rawArgs = rawArgs.filter(a => a !== '--reading' && a !== '-r');
505
+ const show = reading ? showReadingView : showResults;
506
+
507
+ // Explicit arguments take precedence over piped stdin — otherwise running
508
+ // `calliope_ts "some line"` from a script/CI (where stdin is a non-TTY but
509
+ // empty) silently analysed the empty pipe and ignored the argument.
510
+ if (rawArgs.length > 0) {
511
+ // Check if first arg is a file
512
+ const firstArg = rawArgs[0];
513
+ if (fs.existsSync(firstArg) && fs.statSync(firstArg).isFile()) {
514
+ const text = fs.readFileSync(firstArg, 'utf-8');
515
+ show(text);
516
+ return;
517
+ }
518
+ // Otherwise treat as text input
519
+ const text = rawArgs.join(' ');
520
+ show(text);
521
+ return;
522
+ }
523
+
524
+ // No arguments: piped input (file redirect / heredoc) is the document.
525
+ if (!process.stdin.isTTY) {
526
+ const text = fs.readFileSync(0, 'utf-8');
527
+ show(text);
528
+ return;
529
+ }
530
+
531
+ // No arguments: launch interactive REPL
532
+ await replMode();
533
+ }
534
+
535
+ const isMain = process.argv[1] === fileURLToPath(import.meta.url);
536
+ if (isMain) {
537
+ main().catch(err => {
538
+ console.error(chalk.red('Fatal error:'), err);
539
+ process.exit(1);
540
+ });
541
+ }