inkhaven 1.3.13

Inkhaven — TUI literary work editor for Typst books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
# Configuration

Every Inkhaven project carries its own configuration file:
`<project-root>/inkhaven.hjson`. It is written verbatim by `inkhaven init`
from the template that ships with the binary (`assets/default_project.hjson`)
and is hot-reloadable per-session — change a value and restart the TUI to
see it pick up.

Inkhaven uses [HJSON](https://hjson.github.io/), a strict-JSON superset that
allows comments, unquoted keys, optional commas, and multiline strings.
Examples in this document are real HJSON syntax that you can paste straight
into your file.

## Table of contents

- [How the config is read](#how-the-config-is-read)
- [Global overrides](#global-overrides-1220)
- [Top-level fields](#top-level-fields)
- [`embeddings`](#embeddings)
- [`llm`](#llm)
- [`editor`](#editor)
- [`theme`](#theme)
- [`hierarchy`](#hierarchy)
- [`keys`](#keys)
- [`backup`](#backup)
- [`prompts_file` and `language`](#prompts_file-and-language)
- [`typst_compile`](#typst_compile)
- [`output`](#output)
- [`goals`](#goals)
- [`sync_interval_seconds`](#sync_interval_seconds)
- [Migration and forward compatibility](#migration-and-forward-compatibility)

## How the config is read

- The TUI loads `inkhaven.hjson` once on startup and clones the parsed
  result so every subsystem (editor, AI client, theme renderer, backup
  hook) reads it independently.
- Every field is `#[serde(default)]`. Missing fields silently fall back to
  the compiled-in default, so a config written by an older release keeps
  working when new fields are added.
- Unknown fields are ignored. A typo (`heigth: 24`) does not crash the
  loader, but the value has no effect — check `KEYBINDING.md` and this
  document for the canonical names.

You can validate a config without launching the TUI:

```bash
inkhaven --project ~/Books/my-novel list >/dev/null
```

If the config is malformed the CLI prints an error like
`inkhaven: config error: found a punctuator character when expecting a quoteless string` and exits.

## Global overrides (1.2.20+)

You don't have to repeat yourself across projects. After a project's
`inkhaven.hjson` is read, Inkhaven layers any **user-global override files**
on top, so a personal preference (your theme, your keybinds) applies to
*every* project without editing each project's config.

Precedence, lowest → highest:

1. compiled-in defaults
2. the project's `inkhaven.hjson`
3. `~/.config/inkhaven/config.hjson`
4. `~/.config/inkhaven/conf/*.hjson` — every `.hjson` file in that folder,
   in sorted (lexical) filename order, each overriding the previous

So a global file **wins over the project**. This is deliberate: `inkhaven
init` writes a *full* config, so if the project won you'd never see your
global colours. The override files are **partial** — put in only the keys
you want to change; everything else falls through to the project:

```hjson
// ~/.config/inkhaven/config.hjson — applies to all projects
{
  theme: {
    style_warning_echo_fg: "#7aa2f7"
    pane_fg: "#e0e0e0"
  }
}
```

Split overrides across `conf/` if you like — e.g.
`conf/10-theme.hjson`, `conf/20-keys.hjson` — and the higher-numbered file
wins on a conflict. The directory honours `$XDG_CONFIG_HOME` (falling back
to `~/.config`).

A malformed **global** file is skipped with a warning rather than breaking
every project — only a malformed **project** `inkhaven.hjson` is fatal. The
in-app config editor (`Ctrl+B A`) still edits the *project* file directly,
so what it shows is the raw project config, not the global-merged result.

## Top-level fields

```hjson
{
  language: english
  prompts_file: prompts.hjson
  sync_interval_seconds: 60

  embeddings: { … }
  llm: { … }
  editor: { … }
  theme: { … }
  hierarchy: { … }
  keys: { … }
  backup: { … }
}
```

## `embeddings`

Controls how paragraph bodies are converted into vectors for semantic
search. Inkhaven uses [fastembed](https://github.com/Anush008/fastembed-rs)
under the hood.

```hjson
embeddings: {
  model: MultilingualE5Small
  chunk_size: 800
  chunk_overlap: 0.15
  pool_size: 4
}
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `model` | string | `MultilingualE5Small` | Which fastembed model to download / use. Pick a multilingual one (E5) if you write in any non-English language. |
| `chunk_size` | int | `800` | Approximate characters per chunk fed to the embedder. Larger chunks → more context but coarser similarity. |
| `chunk_overlap` | float | `0.15` | Overlap fraction between adjacent chunks. `0.15` = 15 % overlap, smoothing chunk boundaries. |
| `pool_size` | int | `4` | 1.3.12+. r2d2 connection-pool size for each backing DuckDB file (metadata + content). **Clamped to a minimum of 2 at open time** so a background job (e.g. the deep AI refresh) can always check out a connection while the main thread holds another — a pool of 1 would deadlock. Raise it if you script heavy concurrent access; the default 4 is ample for the TUI + one background job. |

Supported model names:

- `MultilingualE5Small` (default) — 384-dim, ~120 MB, fast, good
  multilingual recall including Russian
- `MultilingualE5Base` — 768-dim, ~300 MB, higher quality
- `MultilingualE5Large` — 1024-dim, ~1.1 GB, best multilingual quality
- `BGEM3` — 1024-dim, multilingual, strong English performance
- `BGESmallENV15`, `BGEBaseENV15`, `BGELargeENV15` — English-only,
  smaller binaries

Changing the model triggers a one-time download on next start (the
existing index is rebuilt next time you save a paragraph). If you switch
models you should run `inkhaven reindex` so the new embedder reprocesses
your prose.

## `llm`

Lists AI providers and picks one as the default.

```hjson
llm: {
  default: gemini
  providers: {
    gemini: {
      model: gemini-2.5-pro
      api_key_env: GEMINI_API_KEY
    }
    deepseek: {
      model: deepseek-chat
      api_key_env: DEEPSEEK_API_KEY
    }
    ollama: {
      model: llama3.2
    }
  }
}
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `default` | string | `gemini` | Which entry in `providers` is used when no `--provider` flag is passed (CLI) and no override is hard-coded (TUI). |
| `providers.<name>.model` | string | varies | Model identifier passed to [genai](https://github.com/jeremychone/rust-genai). genai picks the adapter (Gemini / OpenAI / Anthropic / Ollama / …) from this string. |
| `providers.<name>.api_key_env` | string \| absent | varies | Environment variable that holds the API key. **Omit entirely** for local providers like Ollama. |

If `api_key_env` is set and that env var is unset at runtime, Inkhaven
refuses to spawn the inference with a clean status message — no crash,
no half-formed request.

To add an OpenAI provider:

```hjson
openai: {
  model: gpt-4.1-mini
  api_key_env: OPENAI_API_KEY
}
```

To add an Anthropic provider:

```hjson
claude: {
  model: claude-3-7-sonnet-latest
  api_key_env: ANTHROPIC_API_KEY
}
```

Switching the default is one edit: `default: claude` and you're done.

## `editor`

Controls the editor pane's behaviour. The visual look lives in
[`theme`](#theme).

```hjson
editor: {
  theme: default
  tab_width: 2
  wrap: true
  autosave_seconds: 5
  stemming: {
    languages: [
      "english"
      "russian"
    ]
  }
}
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `theme` | string | `default` | Reserved; the visual theme is configured under top-level `theme`. |
| `tab_width` | int | `2` | Currently informational — tui-textarea inserts a literal `\t`. |
| `wrap` | bool | `true` | Soft word-wrap inside the editor. `false` → horizontal scroll on long lines. |
| `autosave_seconds` | int | `5` | Seconds of editor inactivity after which a dirty paragraph is auto-saved. `0` disables idle autosave (Ctrl+S, paragraph-switch and quit-time autosaves still fire). Suspended while a grammar-correction highlight is active. |
| `startup_splash` | bool | `true` | 1.2.4+. Show a 7-second floating splash at launch with today's words / active minutes / streak / project shape. Any key dismisses early. Set `false` to skip. |
| `mouse_captured` | bool | `true` | 1.2.8+. Initial mouse-capture state on launch. `true` hands every mouse event to the TUI (click-to-focus, scroll-wheel per pane, in-TUI drag-select). `false` releases capture at startup so the terminal's native drag-select + system-clipboard copy (Cmd/Ctrl+Shift+C) work without pressing `Ctrl+Shift+M` first. The runtime toggle still flips state regardless. |
| `confirm_quit`   | bool | `false` | 1.2.8+. Pop a confirmation modal when the user presses `Ctrl+Q`. `Y` / `Enter` confirms and quits (with the usual autosave-first behaviour); `N` / `Esc` cancels. Useful when `Ctrl+Q` triggers terminal software flow-control or when the chord lands by accident. Default `false` — `Ctrl+Q` quits immediately as it always has. Ctrl+Q inside an already-open modal still quits unconditionally (intended as an escape hatch). |
| `tts.enabled`    | bool   | `false`   | 1.2.9+. Master switch for the `Ctrl+B S` read-aloud feature. When `false` (default), pressing the chord opens a friendly explanation modal instead of speaking. Set `true` to enable; the OS TTS engine (`tts-rs`: AVFoundation on macOS, SAPI / WinRT on Windows, Speech Dispatcher on Linux) is lazily initialised on first invocation and cached for subsequent reads. |
| `tts.voice`      | string | `"Milena"` | 1.2.9+. Voice-name fragment. Case-insensitive substring match against installed voice names; the matcher prefers entries that also contain `Enhanced` or `Premium`, so `"Milena"` picks `Milena (Enhanced)` when the premium variant is installed. Default `Milena` is a Russian female voice that ships free with macOS (one-time download via System Settings → Accessibility → Spoken Content → Russian) and Windows (Settings → Time & language → Speech → Add a voice). Empty string falls back to the engine's system default voice. On Linux, the voice name depends on which engines speech-dispatcher is configured to use. |
| `tts.speed`      | f32    | `1.0`     | 1.2.9+. Speech rate as a multiplier over the engine's "normal" rate. `1.0` = normal, `0.8` = 80% (slower / more deliberate), `1.2` = 120% (faster). Clamped to the engine's `[min_rate, max_rate]` bounds at playback time, so extreme values silently saturate. |
| `tts.greeting`   | string | `""`      | 1.2.9+. Spoken at TUI startup just after the daily-progress splash, before the main editor frame renders. Non-blocking — speech plays in parallel with the editor coming up. Empty string skips (default). Honoured only when `enabled = true`. Example: `"Welcome back"` (English) or `"Доброе утро, Владимир"` (Russian, paired with `voice: "Milena"` / `"Katya (Enhanced)"`). |
| `tts.goodbye`    | string | `""`      | 1.2.9+. Spoken at TUI shutdown just before terminal teardown. Inkhaven **blocks** up to 5 seconds for the speech to drain so the shell doesn't truncate it — keep the text short (a few words). Empty string skips (default). Example: `"Goodbye"` / `"До скорого"`. |
| `style_warnings.enabled` | bool | `false` | 1.2.9+. Master switch for inline style-warning overlays. When on, the editor underlines stylistically weak prose (currently filter words) in amber so the writer can question + rewrite. `Ctrl+B Shift+F` toggles in-session without rewriting HJSON. |
| `style_warnings.filter_words.enabled` | bool | `true` (if master is on) | 1.2.9+. Filter-word detector — flags intensifier crutches (`just`, `really`, `very`) + hedges (`seemed`, `felt`) + sensory verbs. Built-in word lists ship for `english`, `russian`, `french`, `german`, `spanish`; the active list is keyed by the top-level `language` field. |
| `style_warnings.filter_words.use_stemming` | bool | `true` | 1.2.9+. Match via Snowball stemming so list entries are LEMMAS (e.g. `seem`, `казаться`) and the detector catches every inflection (`seemed` / `seems` / `seeming`, `казался` / `казалась` / `казалось` / `казались`). Set `false` to fall back to exact-lowercased match — you'd then need to list every form individually. |
| `style_warnings.filter_words.<lang>` | array | `[]` | 1.2.9+. Per-language list — one of `english`, `russian`, `french`, `german`, `spanish`. **Empty list = use the built-in default for that language; non-empty = REPLACE the default.** Use `extra_words` for additive overrides. Run `inkhaven doctor --filter-words-snippet` to get the populated built-in lists as HJSON ready to paste under `filter_words`. |
| `style_warnings.filter_words.extra_words` | array | `[]` | 1.2.9+. User-supplied words added **on top of** the language default. Case-insensitive; stemmed when `use_stemming` is on. Example: `["totally", "obviously"]`. |
| `style_warnings.repeated_phrases.enabled` | bool | `true` (if master is on) | 1.2.9+. Repeated-phrase detector — slides an `n`-word window across the paragraph, stems each window, flags every occurrence of any n-gram that repeats `threshold` or more times. Catches writer-crutch gestures like `lifted her shoulders` recurring across a chapter. |
| `style_warnings.repeated_phrases.n` | int | `4` | 1.2.9+. Window size — number of consecutive non-stop words compared. `3` is noisy (catches incidental patterns); `5+` misses most crutches. |
| `style_warnings.repeated_phrases.threshold` | int | `3` | 1.2.9+. Minimum occurrence count before flagging. `2` is too noisy in most prose; `3` is the editing-craft default. |
| `style_warnings.repeated_phrases.use_stemming` | bool | `true` | 1.2.9+. Stem the words before n-gram comparison so `lifted her shoulders` matches `lifting her shoulders`. Disable for exact-form matching. |
| `style_warnings.repeated_phrases.<lang>_stop_words` | array | `[]` | 1.2.9+. Per-language stop-word list (`english_stop_words`, `russian_stop_words`, …) — closed-class words excluded from n-gram comparison so `the dog and X` doesn't repeat-match `the dog and Y`. Empty = use built-in default for that language. |
| `style_warnings.show_dont_tell.enabled` | bool | `true` (if master is on) | 1.2.9+. Show-don't-tell detector — flags telling prose patterns: copula + emotion adjective (`was angry`), manner-of-emotion adverbs (`angrily`), and direct cognition verbs (`realised`, `knew`). Underline colour `style_warning_show_dont_tell_fg` (default `#94e2d5`). Pair with the AI-driven scan via `Ctrl+B Shift+T` for deeper analysis. |
| `style_warnings.show_dont_tell.use_stemming` | bool | `true` | 1.2.9+. Stem entries with the project's Snowball algorithm so inflections collapse — `seemed`/`seems`/`seeming` all match a single `seem` linking-verb entry. Disable for exact-form matching. |
| `style_warnings.show_dont_tell.<lang>_linking_verbs` / `*_emotion_adjectives` / `*_manner_adverbs` / `*_cognition_verbs` | array | `[]` | 1.2.9+. Per-language word lists across the four detector categories. Empty = use built-in default for that language; non-empty = REPLACE the default. **1.2.11+** — curated built-ins now ship for all five supported languages (English / Russian / French / German / Spanish), not just English. Per-genre tuning belongs in `inkhaven show-dont-tell bootstrap <lang>` which uses the configured LLM as a one-shot vocabulary curator. |
| `style_warnings.anachronism.year` | int | _unset_ | 1.3.8+. The manuscript's setting year. **The anachronism detector is OFF until this is set** — a contemporary novel sees nothing. Once set, terms whose earliest plausible year postdates it are flagged (a "wristwatch" in an 1840 novel). Findings surface in `inkhaven edit` (category `anachronism`), jumpable to the exact word. |
| `style_warnings.anachronism.terms` | array | `[]` | 1.3.8+. User additions to the ~35-term built-in lexicon — each `{ term: "spyglass-cam", earliest: 1990 }`. **Additive**, not a replacement: your terms extend the built-ins (telephone 1876, scientist 1834, okay 1839, …). Matched case-insensitively against whole words. |
| `pov_chip_enabled` | bool | `true` | 1.2.9+. Status-bar POV / character chip. When on, the status bar shows the most-mentioned character in the currently-open paragraph (the heuristic POV character) plus up to three additional named characters present. Driven by the project's existing `characters` lexicon — no separate tagging required. `Ctrl+B Shift+P` toggles in-session without rewriting HJSON. Chip colours are themed via `theme.pov_chip_bg` / `theme.pov_chip_fg` (1.2.10+ — explicit RGB defaults `#8b1d88` background / `#ffffff` foreground; tune these if the contrast doesn't read in your terminal palette). |
| `prompt_language_mode` | string | `"book_defined"` | 1.2.11+. Prompt-language resolver mode. `"book_defined"` uses the top-level `language` field for every AI prompt resolution; `"paragraph_detected"` runs `whatlang` on the live paragraph body and falls back to `book_defined` for paragraphs shorter than `prompt_language_detection_min_chars`. `Ctrl+B Shift+N` cycles a session-local override on top of this knob; the AI pane title bar's `lang=` chip reflects the active mode. See `Documentation/PROPOSALS/MULTILINGUAL_PROMPTS.md` for the resolver design. |
| `prompt_language_detection_min_chars` | int | `50` | 1.2.11+. Minimum non-whitespace character count required before `prompt_language_mode = "paragraph_detected"` will attempt whatlang detection. Below this threshold, the resolver silently uses the book language — whatlang is unreliable on short text. Edit-time cache invalidation (on save, on AI-diff accept, on external file change) also uses this value as the length-delta threshold. |
| `stemming.languages` | list of strings | `["english", "russian"]` | **Legacy** — superseded by top-level `language` when that is non-empty. See [`language`](#prompts_file-and-language). |

The grammar-correction-highlight interaction: while you have an active
`g`-apply diff visible, idle autosave is suspended so the red overlay
doesn't disappear under you. Manual save (Ctrl+S) or leaving the editor
pane (focus loss) explicitly clears the overlay and resumes the normal
autosave cadence.

## `theme`

Every colour Inkhaven uses is configurable through this block. Values
are RGB hex strings (`#RRGGBB`) or the short `#RGB` form. Empty string
or an unparseable value falls back to the baked-in default.

The shipping defaults are
[Catppuccin Mocha](https://catppuccin.com/palette/) — a dark, balanced
palette tested on plenty of terminals.

```hjson
theme: {
  // Pane chrome
  pane_bg:           "#1e1e2e"
  pane_fg:           "#cdd6f4"
  line_number_fg:    "#6c7086"
  current_line_bg:   "#313244"

  // Borders
  border_focused:    "#cba6f7"
  border_unfocused:  "#45475a"
  border_dirty:      "#f9e2af"
  border_saved:      "#a6e3a1"
  border_readonly:   "#94e2d5"

  // Floating windows
  modal_bg:          "#181825"
  modal_fg:          "#cdd6f4"
  modal_border:      "#cba6f7"

  // Lexicon overlay
  places_fg:         "#89dceb"
  characters_fg:     "#f9e2af"

  // 1.2.13+ — invented-language word overlay.  Italic
  // when applied (mnemonic: italics is the typesetting
  // convention for foreign-language inclusions in
  // English prose).  Walks every Language/<lang>/
  // Dictionary entry's surface forms (lemma + every
  // paradigm in `inflection:` map) and lights them up
  // in the manuscript editor.
  language_word_fg:  "#b4a8e1"

  // In-buffer search
  search_match_bg:   "#f38ba8"
  search_current_bg: "#f5c2e7"

  // Tree pane chrome
  tree_open_marker:  "#a6e3a1"
  tree_book_fg:      "#f5c2e7"
  tree_chapter_fg:   "#89b4fa"
  tree_subchapter_fg:"#94e2d5"
  tree_paragraph_fg: "#cdd6f4"

  // Editor header
  editor_position_fg:"#89dceb"

  // AI header chips
  ai_scope_fg:       "#fab387"
  ai_infer_fg:       "#94e2d5"

  // Grammar-check change overlay
  grammar_change_fg: "#f38ba8"

  // 1.2.12+ — per-detector style-warning modifier
  // overrides.  Empty string maps to "underline"
  // (the 1.2.9-1.2.11 hardcoded default).  Accepts
  // "underline", "bold", "dim", "reversed",
  // "italic", "none", or "+"-combined like
  // "underline+bold".  Useful on terminal palettes
  // where the teal underline reads faint.
  style_warning_filter_word_modifier:       ""
  style_warning_repeated_phrase_modifier:   ""
  style_warning_show_dont_tell_modifier:    ""
  // 1.3.9+ — the live anachronism overlay.  A warm
  // amber-orange "wrong era" caution (default
  // "#eba672"), distinct from the show-don't-tell teal.
  // Off until you set editor.style_warnings.anachronism
  // .year; rides the master style-warnings toggle.
  style_warning_anachronism_fg:             "#eba672"
  // 1.2.20+ — the live echo overlay (Ctrl+B Shift+K).
  // Its own colour (default a muted purple "#b48ead")
  // so a cross-paragraph echo doesn't read as a
  // within-paragraph repeated phrase; modifier accepts
  // the same grammar as the three above.
  style_warning_echo_fg:                    "#b48ead"
  style_warning_echo_modifier:              ""

  // Typst syntax
  syntax_heading:    "#cba6f7"
  syntax_bold:       "#f9e2af"
  syntax_italic:     "#94e2d5"
  syntax_string:     "#a6e3a1"
  syntax_number:     "#fab387"
  syntax_comment:    "#6c7086"
  syntax_keyword:    "#cba6f7"
  syntax_function:   "#89dceb"
  syntax_operator:   "#94e2d5"
  syntax_list_marker:"#cba6f7"
  syntax_raw:        "#fab387"
  syntax_tag:        "#89b4fa"
  syntax_quote:      "#9399b2"
}
```

### Pane chrome

| Field | What it paints |
| ----- | -------------- |
| `pane_bg` | The background fill of every pane (Tree, Editor, AI, Search, AI prompt). |
| `pane_fg` | Default foreground inside panes. |
| `line_number_fg` | The dim gutter to the left of editor text. |
| `current_line_bg` | The horizontal stripe behind the cursor's line in the editor. |

### Borders

`border_focused` and `border_unfocused` apply to every non-editor pane.
The editor swaps in `border_saved` (green), `border_dirty` (yellow), or
`border_readonly` (teal) **only while focused** so the buffer state is
glanceable.

### Floating windows

Every modal (Add / Delete / Rename / FindReplace / QuickRef /
FilePicker / Help / PromptPicker / SnapshotPicker) shares `modal_bg`,
`modal_fg`, and `modal_border`.

### Lexicon overlay (Places / Characters / Language)

`places_fg` colours any token in the editor that matches a paragraph
title in the **Places** system book; `characters_fg` does the same for
**Characters**. Stemming is applied per the project `language`, so a
Russian project's place "Москва" lights up "Москвы", "Москве", and so on
automatically. See [`LOCATIONS.md`](LOCATIONS.md) and
[`CHARACTERS.md`](CHARACTERS.md).

`language_word_fg` (1.2.13+) colours every word from every Language
sub-book's `Dictionary` chapter — the lemma *and* every paradigm value
in the entry's `inflection: {...}` map.  Rendered ITALIC + colour to
mirror the typesetting convention for foreign-language inclusions in
prose.  Cursor on a Language hit shows `[word · POS · translation]` in
the editor footer (lifted live from the entry's HJSON).  See
Tutorial 49 for the end-to-end Language-book workflow and
[`PROPOSALS/LANGUAGE_BOOK.md`](PROPOSALS/LANGUAGE_BOOK.md) for the
full design.

### In-buffer search (Ctrl+F)

`search_match_bg` paints every match; `search_current_bg` highlights the
one the cursor is sitting on (Ctrl+X advances). Both apply on top of the
syntax colour, so the underlying text stays readable.

### Tree pane

`tree_open_marker` is the colour of the ▸ glyph that flags the
currently-loaded paragraph. The four per-kind colours
(`tree_book_fg`, `tree_chapter_fg`, `tree_subchapter_fg`,
`tree_paragraph_fg`) drive each row's title colour; books and chapters
also get bold so the upper hierarchy has visual weight.

### Editor header chip

`editor_position_fg` colours the trailing `L… C…` cursor read-out in the
Editor pane's title.

### AI header chips

`ai_scope_fg` is the F9 scope chip; `ai_infer_fg` is the F10 inference
mode chip. The chips are always shown (`infer=…` is always visible so an
accidentally-armed Local mode is obvious; `scope=…` appears only when
non-None).

### Grammar-check overlay

`grammar_change_fg` colours every character that differs from the
pre-correction baseline after a `g`-apply in the AI pane. Persists until
save, paragraph switch, or `Ctrl+B C`.

### Typst syntax

The thirteen `syntax_*` fields drive the editor's tree-sitter-based Typst
highlighter. Adjust them to match an external colour scheme you like.

## `hierarchy`

```hjson
hierarchy: {
  unbounded_subchapters: false
}
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `unbounded_subchapters` | bool | `false` | When `false` the hierarchy is exactly **Book → Chapter → Subchapter → Paragraph**. When `true`, subchapters may nest under subchapters arbitrarily — useful for legal documents, deeply structured manuals, etc. |

## `keys`

Several global chords are configurable. Everything else is hard-coded.

```hjson
keys: {
  save:             Ctrl+s
  search:           Ctrl+/
  ai_prompt:        Ctrl+i
  next_pane:        Tab
  prev_pane:        Shift+Tab
  page_up:          PageUp
  page_down:        PageDown
  meta_prefix:      Ctrl+b
  bund_prefix:      Ctrl+z
  view_prefix:      Ctrl+v
  bindings:         []
}
```

| Field | Default | What it does |
| ----- | ------- | ------------ |
| `save`        | `Ctrl+s`     | Save current paragraph. |
| `search`      | `Ctrl+/`     | Focus the top Search bar. |
| `ai_prompt`   | `Ctrl+i`     | Focus the bottom AI prompt bar. |
| `next_pane`   | `Tab`        | Cycle focus Tree → Editor → AI. |
| `prev_pane`   | `Shift+Tab`  | Cycle in reverse. |
| `page_up`     | `PageUp`     | PageUp (used in Tree + Editor; configurable for users on terminals that re-encode it). |
| `page_down`   | `PageDown`   | PageDown. |
| `meta_prefix` | `Ctrl+b`     | The Meta prefix chord. The action table is pane-specific — see [`KEYBINDING.md`](KEYBINDING.md) §1.1. |
| `bund_prefix` | `Ctrl+z`     | The Bund prefix chord (1.2+). |
| `view_prefix` | `Ctrl+v`     | The View prefix chord (1.2.4+) — markdown export, similar-paragraph mode, progress modal, paragraph links, bookmarks, fuzzy picker. |
| `bindings`    | `[]`         | User overlay rebinding sub-chords. Supports `layer: "meta_sub" | "bund_sub" | "view_sub" | "top_level"`. See [`KEYS_REASSIGNMENT.md`](KEYS_REASSIGNMENT.md). |

If your terminal multiplexer eats `Ctrl+B` (tmux uses it as the default
prefix), set `meta_prefix: Ctrl+g` or `Ctrl+;` or similar.

Chord syntax accepts:

- modifier prefixes: `Ctrl+`, `Shift+`, `Alt+`
- bare key names: `Tab`, `Enter`, `Esc`, `PageUp`, `PageDown`, `Home`,
  `End`, `Up`, `Down`, `Left`, `Right`, `Backspace`, `Delete`
- function keys: `F1` … `F12`
- printable characters: literal letter / digit / symbol

Multiple modifiers stack (`Ctrl+Shift+m`).

## `backup`

Drives the `inkhaven backup` CLI and the TUI's auto-backup-on-exit hook.

```hjson
backup: {
  out_dir: "backups"
  max_age: "7d"
  wait_for_key_after_backup: true
}
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `out_dir` | string | `"backups"` | Where `.zip` snapshots land. Relative paths resolve against the project root; absolute paths are used as-is. Created if missing. Empty string disables auto-backup. |
| `max_age` | [humantime](https://docs.rs/humantime) duration | `"7d"` | Maximum age of the last successful backup before the TUI's exit hook creates a fresh one. Values like `"24h"`, `"12h"`, `"30m"`, `"1w"` all work. `"0s"` disables auto-backup but keeps the manual `inkhaven backup` command active. |
| `wait_for_key_after_backup` | bool | `true` | 1.2.6+. When a backup finishes — either the manual `Ctrl+B Shift+B` chord or the exit-hook auto-backup — hold the splash on screen with a `Press any key to continue…` prompt so the user can read the destination path before the TUI dismisses it. Set `false` to keep the 1.2.5 auto-dismiss behaviour. |

`Ctrl+B Shift+B` (1.2.6+) triggers a manual backup that bypasses
the `max_age` cooldown — the splash always fires, the archive is
always written.

When the on-exit hook fires you see a splash:

```
┌── Inkhaven · backup ──────────────────┐
│  Performing database backup…          │
│  Project: /home/you/Books/my-novel    │
│  [████████····]  321/512 ( 63%)       │
└───────────────────────────────────────┘
```

The store handle is dropped before the zip runs so DuckDB / HNSW have
checkpointed to disk and the archive captures a consistent snapshot.

See [`MAINTENANCE.md`](MAINTENANCE.md) for backup / restore commands.

## `prompts_file` and `language`

```hjson
prompts_file: prompts.hjson
language: english
```

| Field | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `prompts_file` | string | `"prompts.hjson"` | Path to the prompt library (resolved against the project root). See [`PROMPTS.md`](PROMPTS.md). |
| `language` | string | `"english"` | Primary writing language. Drives Snowball stemmers for the Places / Characters highlight overlay AND the default F7 grammar-check prompt. Accepts: `arabic, danish, dutch, english, finnish, french, german, greek, hungarian, italian, norwegian, portuguese, romanian, russian, spanish, swedish, tamil, turkish`. Empty string falls back to `editor.stemming.languages`. |

To write a Russian-language novel:

```hjson
language: russian
```

To write multilingual content where the stemmer should know about more
than one language:

```hjson
language: ""
editor: {
  stemming: { languages: ["english", "russian"] }
}
```

## `typst_compile`

Controls `Ctrl+B B` / `Ctrl+B O` ("compile / take the book") and the
typst-as-library knobs introduced in 1.2.5. Both engines ship in
every 1.2.5+ build; the user picks at runtime via the `engine`
field below.

```hjson
typst_compile: {
  engine:                   "external"   // "external" | "inprocess"
  diagnostics:              true         // typst-syntax parse errors on idle/save
  diagnostics_idle_seconds: 2            // debounce for the idle recheck
  semantic_diagnostics:     false        // upgrade idle check to full typst::compile
  bundle_fonts:             true         // ship CM + Linux Libertine in the binary
  use_system_fonts:         true         // also search system fonts
  packages_enabled:         true         // fetch @preview/<pkg> from packages.typst.org
  wait_for_key_after_compile: true       // hold splash after compile finishes
  error_system_prompt:      ""           // override the AI compile-error prompt
}
```

| Field                       | Type   | Default      | Description |
| --------------------------- | ------ | ------------ | ----------- |
| `engine`                    | string | `"external"` | Picks the compiler driving `Ctrl+B B` / `Ctrl+B O`. `external` (default) shells out to the host's `typst` binary on PATH — exact 1.2.4 behaviour. `inprocess` runs `typst::compile + typst-pdf` inside the inkhaven process: no shell-out, no `typst` install required, structured diagnostics with span info. Compile happens on a worker thread so the TUI spinner stays animated. Both engines write the PDF to the same path. |
| `diagnostics`               | bool   | `true`       | When true, run `typst-syntax` against the open paragraph on save and on idle (`diagnostics_idle_seconds`). Parse errors land on the status bar as `typst: line L:C — <message>`. Pure parser — no eval / layout / render, no font setup, no package resolution. Bund and HJSON content types are skipped automatically. Set `false` to suppress entirely. |
| `diagnostics_idle_seconds`  | int    | `2`          | Minimum seconds of editor idle before the typst recheck runs. `0` is allowed (every tick); large values approach "only on save". Piggy-backs on the same idle clock as `editor.autosave_seconds`. |
| `semantic_diagnostics`      | bool   | `false`      | When **true** AND `engine = "inprocess"`, run a full `typst::compile` against the open paragraph in isolation after the parser passes cleanly. Catches semantic errors (undefined functions, type errors, font-not-found) the parser can't see. **False positives are expected** when the paragraph references book-level definitions — the isolated compile doesn't see the assembled preamble. Costs ~20–200 ms per check on warm caches. Has no effect with `engine = "external"`. |
| `bundle_fonts`              | bool   | `true`       | 1.2.5+. Ship Computer Modern and Linux Libertine inside the inkhaven binary so the in-process engine can lay out even on hosts without system fonts. Adds ~10 MB. Set `false` if every host inkhaven runs on already has the fonts your manuscript needs. No effect when `engine = "external"`. |
| `use_system_fonts`          | bool   | `true`       | 1.2.5+. Also search the host's system fonts via fontdb. Combined with `bundle_fonts: true` (the default), you get both. Turn off for reproducible builds where the only allowed fonts are the embedded ones. No effect when `engine = "external"`. |
| `packages_enabled`          | bool   | `true`       | 1.2.5+. When the in-process engine sees `@preview/<pkg>` (or any non-local package id), fetch and unpack it from `packages.typst.org` via `typst-kit`'s package storage. Cached on disk in the platform's standard cache dir (`~/Library/Caches/typst/packages` on macOS, `~/.cache/typst/packages` on Linux, `%LOCALAPPDATA%\typst\packages` on Windows). Set `false` to fail-fast on package imports — useful for hermetic / offline builds. No effect when `engine = "external"`. |
| `wait_for_key_after_compile` | bool   | `true`       | 1.2.6+. When the Ctrl+B B / Ctrl+B O typst-compile splash finishes, hold it on screen with a `Press any key to continue…` prompt so the user can read the "Build complete." / "Build failed." line before control returns to the editor. Cancelled compiles (Esc) skip the wait. Set `false` to auto-dismiss as in 1.2.5. |
| `error_system_prompt`       | string | `""`         | Override the AI system prompt used when `typst compile` returns non-zero. Empty falls back to the baked-in default. |

The diagnostics path is entirely additive — turning it off
restores the exact 1.2.4 behaviour. `engine: "inprocess"` is the
single switch that lights up the in-process compiler; nothing
else needs to change. At TUI startup an `info!` line records
which engine is active so you can confirm the setting took
effect.

**`inprocess` properties in 1.2.5:**

- `@preview/<pkg>` imports work out of the box via the package
  downloader. First fetch of a package is online; subsequent
  uses hit the on-disk cache. Set `packages_enabled: false` to
  fail-fast on package imports (hermetic builds).
- Fonts are bundled (Computer Modern + Linux Libertine) AND the
  host's system fonts are searched. Either source can be
  disabled independently via `bundle_fonts` /
  `use_system_fonts`.
- The PDF bytes match what `typst compile` of the same version
  produces; if you mix `external` and `inprocess` across runs,
  pin the host's `typst` binary to the same release (`0.14.x`
  for 1.2.5) so the output stays byte-identical.

**TUI integration (1.2.5+):**

- **Splash + interrupt** — Ctrl+B B / Ctrl+B O paint a centered
  splash with the spinner, the book title, the active engine
  (e.g. `internal · fonts: bundled + system · @preview: on`
  *or* `external · /usr/local/bin/typst`), elapsed seconds, and
  a footer hint. **Esc** in the splash cancels the compile:
  external engine receives SIGTERM, in-process worker is
  abandoned (it keeps running until typst finishes naturally;
  the foreground unblocks immediately).
- **Autosave before A/B/O** — Ctrl+B A (assemble), Ctrl+B B
  (build), and Ctrl+B O (take) all flush the primary editor
  (and the secondary editor in similar-paragraph mode) to disk
  before the assembler walks `.typ` files. No more "I just
  pressed Ctrl+B B and the build used yesterday's saved
  version".
- **Engine visibility** — Ctrl+B V (credits / version pane)
  carries a `Typst engine` line with the same summary the
  splash uses; the engine identity is also logged at INFO at
  TUI startup so the choice shows up in `inkhaven.log`.

## `output`

Multi-format export hookup for `Ctrl+B O` ("take the book"). Each
format in `extra_formats` is generated alongside the PDF using the
same combined `.typ` source the PDF compile sees.

```hjson
output: {
  // Case-insensitive: "markdown", "tex", "epub" supported in 1.2.3.
  // Unknown entries log a WARN and are skipped. Per-format errors
  // land on the status bar but never abort the take — the PDF is
  // already on disk before extras run.
  extra_formats: ["markdown", "tex"]
}
```

| Field            | Type           | Default | Description |
| ---------------- | -------------- | ------- | ----------- |
| `extra_formats`  | `["str", …]`   | `[]`    | Additional formats produced alongside the PDF on every `Ctrl+B O`. Files land next to the PDF with the same stem (`story-YYYYDDMM-HHMM.md`, …). Empty list = PDF-only, same as 1.2.2. |

The CLI `inkhaven export <fmt>` ignores this list — it picks one
format explicitly. See tutorial
[`15-multi-format-export.md`](Tutorials/15-multi-format-export.md).

## `goals`

Writing-progress goals — fuels the status-bar widget (today /
streak / per-book pace) and the Ctrl+V G progress modal
(sparkline, status-ladder bar, deadline forecasting). All fields
are optional; commenting them out / zero / empty disables that
particular goal but still records events so the modal has
history to show.

```hjson
goals: {
  daily_words: 1500
  active_minutes_daily: 60
  streak_grace_per_week: 1
  auto_promote_on_target: true
  books: {
    story: { target_words: 80000, deadline: "2026-12-31" }
  }
  status_ladder: {
    ready: 1
    final: 3
  }
}
```

| Field                    | Type            | Default | Description |
| ------------------------ | --------------- | ------- | ----------- |
| `daily_words`            | int             | `0`     | Project-wide daily target. Status-bar shows `today N/M words` when non-zero. |
| `active_minutes_daily`   | int             | `0`     | 1.2.4+. Daily active-time target. Active time sums save→save gaps capped at 5 minutes per gap (AFK breaks don't count). Status-bar shows `45m / 60m` when non-zero. |
| `streak_grace_per_week`  | int             | `0`     | Missed days forgiven inside a rolling 7-day window before the streak breaks. `0` = strict, `1` = one rest day allowed per week. |
| `books`                  | map<slug, BookGoal> | `{}` | Per-book targets, keyed by **book slug** (matches `Node.slug`, case-insensitive). |
| `books.<slug>.target_words` | int          | `0`     | Total words the book should reach. `0` hides the per-book pace line. |
| `books.<slug>.deadline`  | str (`YYYY-MM-DD`) | `""` | Date by which `target_words` should be hit. Empty disables deadline pacing. Past-due deadlines collapse to "remaining gap, all at once". |
| `status_ladder`          | map<status, int> | `{}` | Trailing-7-days promotion targets keyed by status name **lowercased** (`ready`, `final`, `third`, `second`, `first`, `napkin`). Modal shows `→ ready: N/M this week`. |
| `auto_promote_on_target` | bool             | `true` | 1.2.4+. When a save crosses a paragraph's `target_words` (set via `Ctrl+V T` or `ink.paragraph.set_target`), advance its status one ladder rung. Idempotent per `(paragraph, status)`; a manual `Ctrl+B R` resets the bookkeeping. Set `false` to keep promotions manual. |

**Today's words** = current total − today's morning baseline.
The baseline is captured per UTC day on project open (idempotent
per day). System books (Help / Scripts / Typst / Prompts / Places
/ Characters / Notes / Artefacts / Research) are excluded from
every aggregate — only user-book manuscript words count.

See tutorial [`17-writing-goals.md`](Tutorials/17-writing-goals.md)
for the full workflow including streak grace examples and pace
forecasting.

## `sync_interval_seconds`

```hjson
sync_interval_seconds: 60
```

| Type | Default | Description |
| ---- | ------- | ----------- |
| int | `60` | Seconds between background calls to `Store::sync()` — flushes the HNSW vector index and checkpoints DuckDB. `0` disables the background timer; saves still trigger sync explicitly. |

You rarely need to touch this. The default is conservative.

## Migration and forward compatibility

- Every field is `#[serde(default)]`. Old configs work with new releases
  out of the box.
- When a field becomes obsolete it remains parseable (silently ignored)
  so downgrading also doesn't break.
- Inkhaven never edits your `inkhaven.hjson` in place. New fields are
  exposed via the documented defaults; you opt in by adding them
  yourself, copying from `assets/default_project.hjson` (or this file).
- To reset the config to shipping defaults: rename the existing
  `inkhaven.hjson`, run `inkhaven init --force` against the same
  project, then re-merge any customisations.

Full annotated template lives at
[`assets/default_project.hjson`](../assets/default_project.hjson) — that
is the same file `inkhaven init` writes verbatim.

---

## 1.2.6 — new HJSON blocks

Two new top-level stanzas land in the 1.2.6 cycle. Both
are opt-in; existing projects upgrade transparently.

### `ai` (1.2.6+) — AI-pane behaviour

```hjson
ai: {
  // 1.2.6+ — record (user, assistant) turns onto the open
  // paragraph's `Node.ai_memory` when Paragraph-scope
  // prompts fire. Subsequent Paragraph-scope prompts
  // pre-pend that memory to the chat-history payload.
  // Visible session chat history is untouched.
  per_paragraph_memory:           false

  // Max total turns (user + assistant) kept per paragraph.
  // Oldest pair evicts first when length exceeds the cap.
  // 0 = disabled regardless of `per_paragraph_memory`.
  per_paragraph_memory_max_turns: 10

  // 1.2.6+ — route `r` (Replace) and `g` (ReplaceCorrected)
  // through a side-by-side diff modal before any bytes
  // change. `a` accepts; `r` rejects; `e` is an alias for
  // `a`. Set false to revert to the pre-1.2.6 immediate
  // apply.
  diff_review_on_apply:           true

  // 1.2.6+ — re-seed the Prompts book on `inkhaven init`
  // AND on every TUI open with the five embedded prompt
  // .example seeds. Idempotent — paragraphs with the same
  // title are skipped.
  reseed_prompt_examples:         true
}
```

All four fields are `#[serde(default)]`; missing block →
default values. The implementation in
`crate::config::AiConfig` carries the canonical types.

### `timeline` (1.2.6+) — story timeline

```hjson
timeline: {
  // Master switch. When false, every timeline chord, CLI
  // subcommand, and Bund word lands a "feature disabled"
  // hint instead of running. Off by default so existing
  // projects upgrade transparently.
  enabled: false

  // Default track label used when an event's `track`
  // field is None. Shown in the swim-lane row header.
  default_track: "main"

  // Calendar configuration. Three preset shapes; `custom`
  // for everything else.
  calendar: {
    // "gregorian" | "sols" | "custom"
    preset: "custom"

    // Name of the base unit (one tick == one of these).
    base_unit: "day"

    // Unit stack, base-first. Each entry's `per_parent`
    // says how many of THIS unit make one of the next
    // (parent) unit. The first entry's per_parent is
    // ignored. `names` is optional — when empty the
    // formatter falls back to numeric.
    units: [
      { name: "day", names: [] }
      { name: "month", per_parent: 30,
        names: ["Frostmoon", "Snowfall", "Greenstart",
                "Bloomtide", "Highsun", "Goldfall",
                "Mistwane", "Stormrise", "Coldgate",
                "Longnight", "Hearthlit", "Yearfall"] }
      { name: "year", per_parent: 12, names: [] }
    ]

    // Seasons (used by Precision::Season fuzz windows).
    seasons: [
      { name: "winter", start_month: 1, span_months: 3 }
      { name: "spring", start_month: 4, span_months: 3 }
      { name: "summer", start_month: 7, span_months: 3 }
      { name: "autumn", start_month: 10, span_months: 3 }
    ]

    // Epoch label appended to positive years.
    epoch_label:        "A"
    // Epoch label for negative years (prequels).
    epoch_before_label: "BA"

    // Format string used by `Calendar::format()`. Tokens:
    //   {year}, {epoch_label}, {epoch_before_label},
    //   {month}, {month-name}, {day}, {hour}
    display_format: "{year}{epoch_label}.{month}.{day}"

    // Optional landmark aliases the parser recognises.
    parse_aliases: [
      { match: "Founding", ticks: 0 }
    ]
  }

  // Swim-lane display knobs.
  display: {
    show_orphans:        true   # synthetic orphan row at the
                                # bottom of the swim lane
    swim_lane_max_rows:  12     # truncate beyond this with a
                                # "+N more" row
    default_zoom:        1.0    # initial ticks-per-cell
  }
}
```

#### Calendar preset shortcuts

`preset: "sols"` expands to a single-unit calendar with
`day` as the only unit, `Sol` as the epoch label, and
`"Sol {day}"` as the format string. Useful for
"days since day zero" timelines (Mars colony stories,
generation ships, anything where the year isn't a useful
unit).

`preset: "gregorian"` expands to a Year / Month / Day
stack with English month names and 30-day months
(approximate — calendars don't model leap years; the
ticks are absolute). Useful for real-world dates.

`preset: "custom"` honours every field above verbatim.

### `inkhaven.hjson` recap (1.2.6 cycle adds)

```hjson
{
  ai: {
    per_paragraph_memory:           false
    per_paragraph_memory_max_turns: 10
    diff_review_on_apply:           true
    reseed_prompt_examples:         true
  }

  timeline: {
    enabled: false
    default_track: "main"
    calendar: { preset: "gregorian" }
    display: {
      show_orphans:       true
      swim_lane_max_rows: 12
      default_zoom:       1.0
    }
  }
}
```

Both stanzas are additive. Removing them restores the
pre-1.2.6 behaviour exactly.

## 1.2.8 — new HJSON blocks

### `scrivener` (1.2.8+) — Scrivener-importer behaviour

```hjson
scrivener: {
  // List of CustomMeta field names (case-insensitive) that
  // `inkhaven import-scrivener` interprets as event dates.
  // For each matching field on an imported paragraph, the
  // value is fed through the project's HJSON-configured
  // calendar; a successful parse attaches `EventData` to the
  // resulting node (event landed at the parsed start tick,
  // no end, no track override).  Bad values are not fatal —
  // they land on the report's error list with the source
  // field name + raw value.
  //
  // Defaults cover the most common English-language
  // Scrivener templates ("Date" / "Story Date" / "Event
  // Date").  Non-English templates customise this list.
  date_fields: ["Date", "Story Date", "Event Date"]
}
```

The pass is gated on `timeline.enabled = true` — Scrivener
date import is a no-op when the project hasn't opted into
the timeline feature, even if the .scriv file carries
CustomMeta dates.  Scrivener field IDs in
`<CustomMetaDataSettings>` are resolved against the
project-level registry; unknown IDs (referenced by an item
but missing from the registry) are silently skipped.

### `editor.mouse_captured` (1.2.8+)

Already documented inline in the `editor` table above.
Sets the initial mouse-capture state on launch; runtime
`Ctrl+Shift+M` still flips it regardless.

### `shell` (1.2.8+) — embedded nushell pane

```hjson
shell: {
  // Whether `Ctrl+Z o` opens the embedded shell pane.
  // Set false to make the chord a status-hint no-op
  // (the engine + nu deps stay linked into the binary
  // either way — saving binary size requires a custom
  // cargo build with --no-default-features once we add
  // a feature flag, currently not gated).
  enabled: true

  // In-memory cap on (command, output) turn pairs the
  // pane retains across the session.  Older pairs roll
  // off the front.  The SQLite history at
  // `.inkhaven/shell_history.db` is uncapped — this
  // bounds working memory + seeds the Up-arrow recall
  // ring on first open of each session.
  max_buffered_turns: 50

  // Per-turn cap on the number of output lines retained
  // from a single command's stdout (and stderr separately).
  // A `cat /var/log/system.log` or `git log` can emit
  // tens of thousands of lines; without this cap they
  // bloat the in-memory ring and slow PgUp/PgDn scroll
  // rendering.  Excess tail is replaced with a
  // "… (N more lines truncated)" marker — output is
  // capped but never silently dropped.  Raise this if
  // you want to retain the full output of large commands
  // (cost: memory + render time grow linearly).
  max_output_lines: 1000

  // 1.2.8+ — basenames of external programs refused
  // before spawn.  Full-screen TUI apps (vim, less, top,
  // tmux, …) cannot run inside the embedded pane: they
  // open `/dev/tty` directly and write escape sequences
  // past the editor's piped stdio, corrupting ratatui's
  // alt-screen surface.  Match is case-insensitive
  // against the program's basename, so `^vim`,
  // `^/usr/bin/vim`, and `^VIM` all hit a `"vim"` entry.
  // The default list covers common editors, pagers,
  // monitors, multiplexers, remote shells, debuggers,
  // fuzzy finders, TTY-needing REPLs, DB clients, and
  // privileged binaries.  Override to add internal tools
  // or to *allow* something the default rejects:
  //   blocked_externals: ["less", "top", "vim"]   // shorter list
  //   blocked_externals: []                       // disable entirely
  blocked_externals: [
    "vim", "nvim", "vi", "view", "ex",
    "emacs", "emacsclient", "nano", "pico", "joe", "jed",
    "mc", "mcedit", "ranger", "nnn", "lf", "yazi",
    "less", "more", "most", "pg",
    "top", "htop", "btop", "atop", "iotop", "iftop", "nethogs", "glances",
    "tmux", "screen", "byobu", "dtach", "abduco",
    "ssh", "telnet", "mosh", "rlogin",
    "gdb", "lldb",
    "fzf", "peco", "sk", "skim",
    "ipython", "irb", "pry",
    "psql", "mysql", "sqlite3", "redis-cli",
    "sudo", "su", "passwd"
  ]

  // 1.2.8+ — wall-clock budget for a single command's
  // evaluation.  After this many seconds the watchdog
  // raises a nu interrupt; if the worker doesn't respond
  // within a 2-second grace window, the engine is
  // restarted (env vars + `def` declarations + `cd`
  // state are lost) and the user sees a friendly
  // explanation.  Set high (e.g. 600) if you legitimately
  // run long-baked pipelines like remote pulls; lower for
  // a tighter "this should be quick" SLA.
  external_timeout_secs: 30

  // Typst markup wrapping a `Ctrl+Z h` → `i` insert.
  // `{output}` is substituted verbatim — the default
  // uses a backtick-delimited typst raw block which
  // bounds the literal without escaping, so embedded
  // quotes / backslashes / pipes survive intact.
  insert_template: "#raw(block: true, lang: \"shell\", `{output}`)"
}
```

The embedded shell loads nushell's full default command
set (`ls`, `where`, `str`, `path`, `into`, …) and runs
in the same process as the editor — no subprocess, no
PTY.  Long-running TTY apps (`vim`, `top`, `less`) are
explicitly out of scope; use a separate terminal for
those.

Per-project history lives at
`<project>/.inkhaven/shell_history.db` (bundled SQLite,
no system dependency).  Survives TUI restart.

`Ctrl+Z O` (Shift) drops the engine + in-memory ring but
leaves the on-disk DB alone.  Full reset is manual:
`rm .inkhaven/shell_history.db` from another terminal.

See [`Tutorials/35-embedded-shell.md`](Tutorials/35-embedded-shell.md)
for the full chord ladder + use-case walkthrough.

## 1.2.14 — new HJSON blocks

### `project` (1.2.14+) — project word-count goal

Drives the `Ctrl+V Shift+G` project-goal modal.
Optional block — when omitted, the modal explains
how to add it.

```hjson
{
  project: {
    // Total word count target across the books named
    // in `counted_books` (empty list = all user books).
    word_count_goal: 80000

    // ISO date the goal should be hit by.  Used to
    // project finish-date deltas in the modal verdict.
    target_date: "2026-12-31"

    // Books that count toward the goal.  Empty list
    // means "every top-level user book" (right for
    // single-novel projects).  Name explicit books for
    // RPG sourcebooks or anthology projects.
    counted_books: []
  }
}
```

Verdict glyphs in the modal: `✓ Ahead` (projected
finish < target), `· On track` (within 7 days),
`✗ Behind` (projected finish > target + 7), `✓
Complete` (current count ≥ goal).  Projection uses
the 30-day word delta from `progress_cache.sparkline`
— a rolling rate that matches the writing-progress
modal's pace calculation.

### `editor.continuation_anchor_count` (1.2.14+)

How many previous paragraphs the `Ctrl+V d` AI
continuation envelope sends as voice anchors.
Default: `3`.  Higher = the LLM has more voice
context but the envelope grows; cap around 5-6 for
typical paragraph sizes.

```hjson
{
  editor: {
    continuation_anchor_count: 3
  }
}
```

### `editor.footnote_style` (1.2.14+)

Inline footnote markup style for the `Ctrl+V f`
insertion.  Two values:

* `"typst"` (default) — inserts `#footnote[<body>]`
  at the cursor.  The assembled-book renderer
  honours this directly.
* `"markdown"` — inserts `[^id]` at the cursor and
  appends `[^id]: <body>` after the paragraph.
  Use when exporting to markdown-only targets.

```hjson
{
  editor: {
    footnote_style: "typst"
  }
}
```

### `snippets` (1.2.14+) — editor snippet expansion

Trigger-keyed text expansions.  When the editor
sees a trigger followed by Space, it replaces the
trigger with the expansion.  Trigger keys
conventionally start with `\` to avoid clashes
with prose.

```hjson
{
  snippets: {
    "\\dt": "{datetime}"
    "\\sl": "{slug}"
    "\\au": "— {author}"
    "\\todo": "TODO ({date}): {cursor}"
  }
}
```

Built-in placeholders:

| Placeholder | Expands to |
|-------------|------------|
| `{date}` | Today's date, ISO 8601 (`2026-05-31`) |
| `{time}` | Now, 24h (`14:23`) |
| `{datetime}` | `{date} {time}` |
| `{slug}` | Open paragraph's slug |
| `{book}` | Containing book's title |
| `{chapter}` | Containing chapter's title |
| `{author}` | `top_level.author` from inkhaven.hjson |
| `{cursor}` | Marker that controls post-expansion cursor position.  After the expansion pastes, the cursor jumps to where `{cursor}` was (instead of ending at the tail of the pasted text). |

Snippets without `{cursor}` paste atomically.  See
Tutorial 51 for the full snippet workflow.

Three picker-based placeholders (`{char_lookup}` /
`{place_lookup}` / `{artefact_lookup}`) and the
`bund:` prefix for Bund-VM expansion are queued for
a future release — they need an async snippet state
machine the current synchronous pipeline doesn't
yet have.

## 1.2.15 — new HJSON blocks

### `health` (1.2.15+) — background health monitor

The background health monitor catches project
inconsistencies before they cause data loss
(missed backups, orphan rescue files from
prior panics, etc.).  Disabled by default
so existing projects don't inherit a new
background task without opting in.

```hjson
{
  health: {
    enabled: false           // flip to true to spawn
                             // the monitor task
    auto_repair: {
      rescue_orphans: false  // when true, delete
                             // *.inkhaven-rescue
                             // files older than 30d
                             // automatically.  Use
                             // only on projects
                             // where you know
                             // you've reviewed
                             // every crash report.
    }
  }
}
```

When enabled, the monitor runs three checks at
independent cadences:

* **Project root reachable** (90 s) — Critical on
  missing, Warning on type mismatch.
* **Backup freshness** (5 min) — Warning when the
  newest backup is older than `backup.max_age`.
* **Rescue file orphans** (1 h) — Warning when
  `*.inkhaven-rescue` files older than 7 days
  exist under the project.  Auto-repair (when
  enabled) deletes files older than 30 days.

Findings drive the status-bar `health` chip:
`✓` clean, `✎` repaired, `⚠` warning, `✗` error.
Every non-`Ok` event is appended to
`<project>/.inkhaven/health.log` (size-rotated at
1 MB × 5 archives).

See [Tutorial 52](Tutorials/52-health-and-doctor.md)
for the full workflow.

### `scripting.trust_decision` (1.2.15+)

Gate for the auto-load of `Scripts` system-book
paragraphs at project open.  Default `"ask"`.

```hjson
{
  scripting: {
    trust_decision: "ask"  // "ask" | "trust" | "deny"
  }
}
```

Three values:

* **`"ask"`** (default) — scripts run only when
  `<project>/.inkhaven/trust` exists and contains
  the marker line `trust`.  Without that file the
  scripts are skipped and a warning lands in
  `.inkhaven.log`.
* **`"trust"`** — scripts run unconditionally.
  Use only on projects you authored or audited.
* **`"deny"`** — never run scripts regardless of
  the trust file.  Useful for read-only review.

The trust file lives outside the project sources
by convention (gitignored, since `.inkhaven/`
holds machine-local state); an attacker shipping
a project cannot pre-grant trust to themselves
via the file.  The HJSON `"trust"` value is the
project author's declaration "I wrote these
scripts" — recipients of a shared project should
audit before keeping that value.

See [Tutorial 53](Tutorials/53-bund-trust-gate.md)
and `Documentation/SECURITY_WARNING.md` §3.2.

### `scripting.fs_unsandboxed` (1.2.15+)

When `true`, `ink.fs.read` and `ink.fs.write`
operate on unrestricted paths.  Default `false`:
the words confine their paths to the project
root via `crate::path_safety::resolve_within`.

```hjson
{
  scripting: {
    fs_unsandboxed: false  // default — paths
                           // confined to project root
  }
}
```

The sandbox applies whether or not the `fs_read`
/ `fs_write` category gates are enabled.  Set
`true` only on trusted projects where a script
genuinely needs to reach a shared location
outside the project tree.

The 1.2.15 audit identified the previous
unsandboxed behaviour as a privilege risk: a
Bund script with `fs_write` enabled could
overwrite anywhere the user could reach
(`~/.ssh/authorized_keys`, system files with
sudo, etc.).  Confinement is the safer default.

## 1.2.16 — new HJSON blocks

### `backup.amber_threshold` (1.2.16+)

The 1.2.15 health monitor's backup-freshness
check went straight from Ok → Warn at
`backup.max_age`.  1.2.16 adds an intermediate
"amber" state so the user has visibility BEFORE
the warn fires.

```hjson
{
  backup: {
    max_age: "30d"         // 1.2.15 — when warn fires
    amber_threshold: 0.5   // 1.2.16 — when amber chip
                           //          appears (50% of
                           //          max_age by default)
  }
}
```

* `amber_threshold` is a fraction in `[0.0, 1.0]`.
* When backup age ≥ `amber_threshold × max_age`
  but < `max_age`, the status-bar chip turns
  amber with the `ℹ` glyph (Severity::Info).
* Above `max_age`, the existing 1.2.15
  Warning path takes over (yellow chip).

Set `0.0` to disable the amber tier (chip
behaves as 1.2.15).  Set `1.0` and the chip
flips straight to Warn (also 1.2.15 behaviour).

### `editor.show_glossary_chip` (1.2.16+)

Toggles the worldbuilding-density chip in the
status bar.

```hjson
{
  editor: {
    show_glossary_chip: true  // default
  }
}
```

When enabled, the status bar shows a
`<N>C·<N>P·<N>A` chip — cumulative counts of
**C**haracters / **P**laces / **A**rtefacts
entries across the system books.  Auto-hides
on fresh projects (all three counts zero) so
empty-project users don't see noise.

Useful at-a-glance check for cast density:
30 chapters in with only 4 named characters
tells you the cast is thin; 50 named places
across a 200-paragraph manuscript tells you
the reader has a thicket to navigate.

### `editor.show_facts_chip` (1.2.21+)

Toggles a Facts chip in the status bar — `⚑<N>`,
the number of entries in the **Facts** system
book (the world's invariants).  Off by default
(opt-in); auto-hides when the Facts book is
empty.

```hjson
{
  editor: {
    show_facts_chip: true
  }
}
```

A companion to the glossary chip: it surfaces
how richly the world's ground rules are
documented, so you notice a 40-chapter
manuscript still running on three facts.

### `snippets` — `bund:` prefix + picker placeholders (1.2.16+)

Extends the 1.2.14 `snippets` block.  No new
config keys — the additions live inside
existing snippet bodies.

```hjson
{
  snippets: {
    enabled: true                   // 1.2.14
    expand_on: ["space", "tab"]     // 1.2.14
    bindings: [
      // Bund-prefix bodies (NEW in 1.2.16):
      { trigger: ";today",
        body: "bund:ink.now \"%Y-%m-%d\" ink.fmt" }
      { trigger: ";doubled",
        body: "bund:40 2 *" }

      // Picker placeholder bodies (NEW in 1.2.16):
      { trigger: ";cmeets",
        body: "She turned to {char_lookup}." }
      { trigger: ";at",
        body: "In {place_lookup}, the air was thin." }
      { trigger: ";holds",
        body: "He held the {artefact_lookup} aloft." }
    ]
  }
}
```

#### `bund:` prefix

When the snippet body starts with `bund:`, the
remainder is interpreted as a Bund-VM program.
The top-of-stack value at program end (coerced
to string) becomes the expansion.

* Sync placeholders are expanded BEFORE
  evaluation, so `bund:"{author}" ink.print`
  injects `{author}` first then runs the
  program.
* Script execution honours the trust gate +
  category policy — a `bund:` body that calls
  a `store_write` word in an untrusted project
  is denied with a warning.
* Empty stack / non-string top → empty
  expansion (snippet pastes nothing) plus a
  status-bar warning.

#### Picker placeholders

Three new placeholders interrupt expansion to
ask the author which entity to insert:

| Placeholder | Picker |
|-------------|--------|
| `{char_lookup}` | Characters system book |
| `{place_lookup}` | Places system book |
| `{artefact_lookup}` | Artefacts system book |

Behaviour:

* The leading sync placeholders (`{author}`,
  `{date}`, etc.) are resolved first.
* Text before the picker placeholder is pasted
  at the cursor immediately.
* The corresponding picker modal opens; on
  Enter the picked entry name + the tail of
  the snippet body are inserted at the cursor.
* Esc cancels — the head stays inserted, the
  tail is dropped.
* Only the **first** picker placeholder in a
  body fires the modal; any subsequent
  placeholders pass through as literal text
  (a documented limitation, can be lifted in
  a follow-up cycle by queueing modal
  continuations).

See [Tutorial 41 — Snippets](Tutorials/41-snippets-and-expansion.md)
for the full snippet reference and
`Documentation/RELEASE_NOTES/1.2.16.md` Phase
P.6 for the implementation log.

## 1.2.17 — new HJSON blocks

### `editor.tts` (extended for Piper)

1.2.9 introduced the `editor.tts.*` block for the
macOS `say` backend.  1.2.17 keeps every 1.2.9
field intact + layers a backend-agnostic engine
on top so the same chord (`Ctrl+B S`) can route
to either macOS `say` (the System backend) or
the new neural Piper backend.

```hjson
{
  editor: {
    tts: {
      // 1.2.9+ (preserved)
      enabled: false
      voice: "Milena"
      speed: 1.0
      greeting: ""
      goodbye: ""

      // 1.2.17+ (new)
      engine: "auto"
      voices_dir: ".inkhaven/voices"
      auto_download: true
      catalog_url: "https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json"
      catalog_ttl_hours: 24
      binary_path: null
      auto_download_binary: true
      cache_max_voices: 5
      play_command: null
      sample_rate_hz: 22050
      auto_gitignore: true
    }
  }
}
```

Per-field reference:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `engine`              | string  | `"auto"` | Backend selector: `"auto"` prefers Piper if resolvable + falls back to System; `"piper"` forces Piper (errors if unresolvable); `"system"` forces the 1.2.9 macOS `say` backend. |
| `voices_dir`          | string  | `".inkhaven/voices"` | Directory for the Piper voice cache + catalog snapshot, resolved relative to the project root via `crate::path_safety::resolve_within_str`.  Absolute paths + `..` traversal are rejected at startup. |
| `auto_download`       | bool    | `true`   | When `true`, missing voices are streamed from `catalog_url` on first use.  When `false`, missing voices produce a clear "voice X is not downloaded; run `inkhaven tts voice download X`" error. |
| `catalog_url`         | string  | Hugging Face piper-voices `voices.json` | Voice catalog URL.  Override only if you maintain a private / mirrored catalog with the same JSON shape. |
| `catalog_ttl_hours`   | u32     | `24`     | How long the local catalog cache is fresh.  After expiry the next voice operation re-fetches.  Network failures during refresh fall back to the stale cache + log a warning rather than blocking synthesis. |
| `binary_path`         | string? | `null`   | Explicit path to a `piper` binary.  When `null`, inkhaven autoresolves via PATH then `~/.cache/inkhaven/piper-<plat>/`.  Treats the explicit override as authoritative — doesn't silently fall back to PATH if the path is set but unreadable. |
| `auto_download_binary`| bool    | `true`   | When `true`, the `inkhaven tts binary download` CLI fetches the platform-appropriate Piper release from GitHub.  TUI startup never auto-downloads — the chord-triggered surfaces always assume a pre-resolved binary. |
| `cache_max_voices`    | usize   | `5`      | LRU cap on the project's `voices_dir`.  Beyond the cap, the least-recently-used voice's `.onnx` + `.onnx.json` are removed.  Voice models are 25–100 MB each. |
| `play_command`        | string? | `null`   | Override the platform default playback command.  `{path}` is replaced with the resolved WAV path at spawn time.  Default per platform: `afplay {path}` (macOS), `paplay {path}` → `aplay {path}` fallback (Linux), `powershell -c "(New-Object Media.SoundPlayer '{path}').PlaySync()"` (Windows). |
| `sample_rate_hz`      | u32     | `22050`  | Sample rate for Piper synthesis output.  Piper's native rate is 22050 Hz; changing this triggers a resample inside the playback pipeline.  Most users should leave the default. |
| `auto_gitignore`      | bool    | `true`   | When `true`, the first auto-downloaded voice appends `.inkhaven/voices/` to the project's `.gitignore` (creating the file if absent).  Voices are large opaque blobs; checking them into git is universally wrong.  One-time, idempotent, atomic via `crate::io_atomic`. |

See [Tutorial 56](Tutorials/56-tts-piper.md) for the
full Piper workflow including the `Ctrl+B Shift+V`
voice picker, the `inkhaven tts` CLI surface, and the
known Apple-Silicon Piper limitation.

## 1.2.18 — new HJSON blocks

### `editor.reading_time_chip` + `editor.reading_wpm` (1.2.18+)

The R.3 reading-time chip + R.4 reader-pace preview
both read at a configurable words-per-minute.

```hjson
{
  editor: {
    reading_time_chip: false   // default — opt in
    reading_wpm: 200           // default
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `reading_time_chip` | bool | `false` | Show a status-bar chip for the current book: `📖 <remaining> / <total>` read-aloud length at `reading_wpm`, where remaining counts from the open paragraph to the book's end.  Off by default (the status bar is already busy); opt in when targeting an audiobook length or word budget.  Cheap — one O(n) walk of the current book's paragraphs. |
| `reading_wpm` | u32 | `200` | Words-per-minute for the reading-time chip, the reader-pace preview (`Ctrl+B Shift+E`), and the per-chapter timing displayed by the audiobook export.  200 ≈ silent-reading average; ~150 ≈ audiobook narration; ~300 ≈ a fast reader. |

See [Tutorial 58](Tutorials/58-reading-pace.md).

## 1.2.19 — new HJSON blocks

### `editor.echo_*` (1.2.19+)

Tunables for the C.1 `echo-repetition` doctor scan (a
distinctive word reused close together).

```hjson
{
  editor: {
    echo_window: 5        // default
    echo_min_repeats: 3   // default
    echo_max_global: 40   // default
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `echo_window` | usize | `5` | Window (consecutive paragraphs) for the echo scan.  A distinctive word reused `echo_min_repeats` times within this many paragraphs is flagged. |
| `echo_min_repeats` | usize | `3` | Occurrences within `echo_window` required to flag.  Lower = more sensitive. |
| `echo_max_global` | usize | `40` | Distinctiveness ceiling: words used more than this many times across a chapter are treated as common vocabulary (legitimately reused) and skipped, even when clustered.  Tune up for long works, down for short stories. |
| `echo_overlay` | bool | `false` | Default state of the live echo overlay (`Ctrl+B Shift+K`, 1.2.20+ C.1.b): underline, in the open paragraph, words echoing across nearby paragraphs — the inline companion to the `echo-repetition` doctor scan, reusing the three `echo_*` tunables above.  Painted in `theme.style_warning_echo_fg` (default `#b48ead`, distinct from the repeated-phrase overlay).  The session toggle overrides this; set `true` to always start on. |

### `editor.paragraph_long_secs` (1.2.20+)

Threshold for the R.3.b `paragraph-too-long` doctor scan.

```hjson
{
  editor: {
    paragraph_long_secs: 180   // default
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `paragraph_long_secs` | u32 | `180` | A paragraph whose estimated read time at `reading_wpm` exceeds this many seconds is flagged `paragraph-too-long` (Info, no autofix) — a wall of text the reader meets in one unbroken block.  Default 180s ≈ 600 words at 200 wpm.  Set lower to flag denser paragraphs; the finding is author-judgment (length can be a deliberate run-on). |

### `editor.disk_warn_mb` (1.2.20+)

```hjson
{
  editor: {
    disk_warn_mb: 100   // default; 0 disables
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `disk_warn_mb` | u64 | `100` | Low-disk pre-flight threshold (MiB).  When the volume holding the project has less than this much free space, the editor shows a one-time status-line warning at startup, before a session of edits or a long export.  Atomic writes already fail safely on a full disk (the original file survives, the error surfaces) — this is the proactive heads-up.  `0` disables the check. |

### `editor.warn_uncommitted_on_exit` (1.2.20+)

```hjson
{
  editor: {
    warn_uncommitted_on_exit: true   // default
  }
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `warn_uncommitted_on_exit` | bool | `true` | On quit, if the project is a git repo with uncommitted changes (modified, staged, or untracked paths), confirm before exiting (`y`/Enter quits anyway, `n`/Esc stays).  The open paragraph is autosaved first, so the working tree reflects your latest edits.  Best-effort: silently skipped when the project isn't a git repo or `git` isn't installed (inkhaven shells out to your own `git`; it has no git dependency).  Set `false` to quit without the check. |

### Revision sidecars (1.2.19+)

The C.3 / C.4 revision features store their AI-extracted
data under `<project>/.inkhaven/`:

| File | Written by | Read by |
|------|-----------|---------|
| `continuity.json` | `inkhaven continuity extract` | `inkhaven continuity list` + the `continuity-drift` doctor scan |
| `tensions.json` | `inkhaven tension scan` | `inkhaven tension list` + the `unresolved-tension` doctor scan |

These are machine-generated; edit them by hand only if you
know the shape.  Both record the extraction language so the
drift / matching comparison uses the right stemmer.  Add
`.inkhaven/` to `.gitignore` if you don't want them tracked.

The `numeric-contradiction` scan's quantity lexicons
(number-words, units, directions) are **built in** for
English / French / Spanish; other languages skip the scan
cleanly (Russian + German + a `bootstrap-continuity` seed
CLI land in a follow-up).

See [Tutorial 59](Tutorials/59-revision-and-continuity.md)
for the full revision workflow + the multilingual-coverage
table, and [Tutorial 60](Tutorials/60-manuscript-format.md)
for the manuscript-format export.

## 1.3.0 — PDF production blocks

The PDF-1 subsystem (`inkhaven pdf …`, the `ink.pdf.*` Bund words, and
the `imposed_pdf` / `cover_pdf` book-takes) reads three new top-level
blocks. All merge through the normal cascade (project `inkhaven.hjson` →
global `~/.config/inkhaven`, global wins). See
[Tutorial 65](Tutorials/65-hand-binding.md) for the end-to-end workflow.

### `imposition` (1.3.0+) — folding-signature profiles

Named profiles for `inkhaven pdf impose --config <name>`, the `Ctrl+B Q`
preview, and the `imposed_pdf` book-take. Six are built in — `default`
and `chapbook` (A-series), `us_perfect` and `us_chapbook` (their Tabloid /
US-Letter analogues), `thick` (8-sheet push-out signatures for heavy
books), and `a5_book` (1.3.13 — a perfect-bound **A5 codex** folded from A4
sheets: gathered 16-page signatures with shingle creep and a
signature-number mark, the popular home-binding recipe) — and you add your
own keys to extend (a missing profile is an error listing the known names).

For a quick saddle-stitch booklet with **no profile at all**, use
`inkhaven pdf booklet <input>` (1.3.13): it builds the parameters directly,
auto-fitting the press sheet to two source pages side-by-side (so any trim
size works), one nested signature, balanced blanks. `--sheet <preset>`
centres the spread on a named sheet instead; `--creep` adds shingle
compensation; `--no-marks` drops crop/fold marks; `--dry-run` previews.

```hjson
imposition: {
  profiles: {
    default: {
      style: "perfect_bound"          // perfect_bound | saddle_stitch | side_stab
      sheets_per_signature: 4
      target_sheet_size: "A3"          // a preset name, or { width_mm, height_mm }
      orientation: "auto"              // auto | portrait | landscape
      margins: { bleed_mm: 3.0, crop_offset_mm: 5.0, fold_mark_length_mm: 8.0 }
      creep: {
        enabled: true
        paper_stock: "uncoated_80gsm"  // caliper source for the shingle
        thickness_mm_override: null    // set to bypass the stock caliper
        strategy: "shingle"            // shingle | pushout | none
      }
      marks: {
        crop: true, fold: true, registration: true,
        spine_marker: true, signature_number: true, color_bar: false
      }
      blank_page_policy: "append"      // append | balance
    }
    // chapbook: saddle_stitch, A4, creep off — a single folded booklet.
  }
}
```

### `cover` (1.3.0+) — cover/spine defaults

House defaults for `inkhaven pdf cover` and the `cover_pdf` book-take.
The spine width is **computed** from the interior page count + these
paper stocks (`spine = pages × interior_caliper × 0.5 + 2 × cover_caliper
+ binding allowance`); CLI flags (`--width-mm` / `--height-mm` /
`--spine-mm`) override per run.

```hjson
cover: {
  front_width_mm: 152.0      // 6 in
  front_height_mm: 229.0     // 9 in trade
  bleed_mm: 3.0
  interior_stock: "uncoated_80gsm"
  cover_stock: "cover_250gsm"
  spine_font_size_pt: 11.0    // max size; auto-shrinks to fit the spine
  image_fit: "cover"          // cover (default) | fit | stretch
}
```

`image_fit` controls how the front art fills its region: `cover`
(aspect-preserving full-bleed crop — the right default), `fit` (scale to
fit inside, may leave gaps), or `stretch` (distort to fill). Per run,
`inkhaven pdf cover --fit <mode>` overrides it.

### `preflight` (1.3.0+) — print-readiness DPI targets

Selectable targets for `inkhaven pdf preflight --profile <name>` (the
`--dpi N` flag overrides). `strict` uses 300 dpi with no override.

```hjson
preflight: {
  default_profile: "hand_binding"   // hand_binding | print_shop | strict
  hand_binding_dpi: 300
  print_shop_dpi: 300
}
```

Beyond the DPI target, every profile also reports the press hazards that
silently change on output — **overprint**, **transparency** (alpha < 1,
non-Normal blend modes, soft masks), and **spot colours** (Separation /
DeviceN colorants, one plate each) — as warnings; there are no knobs to
turn these off.

### `output` additions (1.3.0+)

Two `output.extra_formats` entries operate on the just-built PDF rather
than the `.typ` source, so they run after the PDF lands:

- `imposed_pdf` — impose with `output.imposed_pdf_config` (a profile name
  from `imposition.profiles`, default `"default"`), writing
  `…-imposed.pdf`.
- `cover_pdf` — generate `…-cover.pdf` from the page count + the `cover:`
  block (title from the book, no barcode).

```hjson
output: {
  extra_formats: ["imposed_pdf", "cover_pdf"]
  imposed_pdf_config: "default"
}
```

## 1.3.8 — world-consistency blocks

### `facts` (1.3.8+) — series-shared canon

A top-level `facts` block lets a multi-book series share one canon. Point
`shared_path` at a directory of plain-text fact files — one file per fact,
the filename (de-slugified) is the title and the contents are the body:

```hjson
facts: {
  shared_path: "../series-bible/facts"
}
```

- `inkhaven facts check` then layers the shared canon under each book's
  local Facts book (**local wins** on a title clash) before running the
  internal-consistency AI pass — so a contradiction between this book's
  prose and the series bible is caught.
- `inkhaven facts import` copies a snapshot of `shared_path` into *this*
  book's Facts book as paragraphs (idempotent — skips ones already
  present; dry-run by default, `--yes` to write, `--from <dir>` to override
  the configured path).

Unset = no series sharing; each book's Facts book stands alone.

### `editor.style_warnings.anachronism` (1.3.8+) — era checking

See the `style_warnings.anachronism.year` / `.terms` rows in the [`editor`
table](#editor) above. Set the manuscript's setting `year` to arm the
detector; terms postdating it (built-in ~35-term lexicon plus your
additive `terms`) are flagged in `inkhaven edit` under category
`anachronism`.

## 1.3.10 — semantic-drift block

### `drift` (1.3.10+) — divergent-description retrieval

`inkhaven drift scan` finds descriptions of the same entity (character /
place / artefact) that diverge across the manuscript without a hard fact
clash. It **reuses the existing on-save vector index** to retrieve each
entity's description paragraphs, then asks the LLM which contradict; the
`drift` block tunes the retrieval that bounds the AI prompt:

```hjson
drift: {
  top_k: 24          // vector hits pulled per entity before name-filtering
  max_snippets: 8    // descriptions kept per entity (bounds the judge prompt)
}
```

- `top_k` (default `24`) — how many semantic-search hits to pull per entity
  before keeping only the paragraphs that actually name it. Raise it for a
  long book where an entity is described many times; lower it to go faster.
- `max_snippets` (default `8`) — the cap on descriptions sent to the judge
  per entity. The larger it is, the more thorough (and more expensive) each
  entity's check. Findings surface in `inkhaven edit` (category `drift`,
  jump-only) and the `Ctrl+V Shift+L` story bible.

## 1.3.13 — per-language detector maps

The style + drift detectors key off the top-level `language` field. Five
languages ship fully curated (`english`, `russian`, `french`, `german`,
`spanish`); 1.3.13 lets you curate **any** language through per-language
**maps** (keyed by language name) instead of the old five fixed fields. An
uncurated language gets **empty** built-ins (no false flags) — never the
English lists. Check coverage with **`inkhaven lang status [--language <l>]`**,
or have the LLM populate every map at once with **`inkhaven lang bootstrap
<language> [--yes]`** (it writes exactly these paths, with a backup +
comment-preserving in-place patch).

```hjson
editor: {
  style_warnings: {
    filter_words:     { languages: { polish: [ "tylko", "naprawdę", … ] } }
    show_dont_tell:   { languages: { polish: {
      linking_verbs:      [ … ]   // copula / quasi-copula asserting inner state
      emotion_adjectives: [ … ]   // adjectives that name an emotion outright
      manner_adverbs:     [ … ]   // emotion-labelling adverbs
      cognition_verbs:    [ … ]   // verbs that narrate thought
    } } }
    repeated_phrases: { languages: { polish: [ "i", "w", "na", "że", … ] } }
  }
}
drift: {
  pronouns: {
    polish: {
      character: [ "on", "ona", "oni", … ]  // 3rd-person, standalone words only
      place:     [ "tam", "tu", … ]
      artefact:  [ "to", "ten", … ]
    }
  }
}
```

- All words are **lemmas** — Snowball stemming (18 languages) catches the
  inflections; set `use_stemming: false` to match exactly instead.
- An entry under `languages.<lang>` is the curated list for that language; the
  old five fixed fields still deserialize and remain valid.
- Two complete worked examples ship in
  [`custom_languages/`](../custom_languages/): **Arabic** (RTL, pro-drop) and
  **Hungarian** (agglutinative, genderless 3rd person).

### Localized world-check prompts

The four AI world-consistency scans (`facts check` / `facts scan` / `drift` /
`continuity`) run with prompts **localized** to the project language for the
five curated languages and force their findings' explanations into that
language. An uncurated language falls back to the English prompt **with a
warning**. Each prompt is overridable through the 3-tier cascade — a `Prompts`
book entry (`{slug}-{lang}` → `{slug}`) → `prompts.hjson` → the localized
built-in (see [`PROMPTS.md`](PROMPTS.md)).