inkhaven 1.2.6

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
# 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)
- [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.

## 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
}
```

| 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. |

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. |
| `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"

  // 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"

  // 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)

`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).

### 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, wiki-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.