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
use std::io::{self, Write};
use std::path::Path;
use tracing::info;
use crate::config::{Config, DEFAULT_PROJECT_CONFIG, DEFAULT_PROMPTS};
use crate::error::{Error, Result};
use crate::project::{PROMPTS_FILE_DEFAULT, ProjectLayout};
use crate::store::Store;
/// Initialise a new project at `path`. If the directory already exists we
/// require explicit consent before wiping it — either the `--force` flag or
/// a `y` answer to the interactive prompt. After confirmation the entire
/// directory is removed and freshly re-created so the new database starts
/// from a clean slate (stale `metadata.db` + `vectors/` from a previous
/// install never trip up the schema).
pub fn run(path: &Path, force: bool, template: &str) -> Result<()> {
let layout = ProjectLayout::new(path);
if path.exists() {
// Either the user passed --force (non-interactive overwrite) or
// we must ask. Anything else aborts cleanly.
let confirmed = if force {
true
} else {
confirm_overwrite(path)?
};
if !confirmed {
return Err(Error::Store(format!(
"init aborted — `{}` left untouched",
path.display()
)));
}
// Refuse to recursively delete the project root if the cwd lives
// inside it (Mac/Linux happily wipes itself out of the cwd and
// hands back an EINVAL on every subsequent operation).
if let Ok(cwd) = std::env::current_dir() {
if let Ok(abs_target) = std::fs::canonicalize(path) {
if cwd.starts_with(&abs_target) {
return Err(Error::Store(format!(
"refusing to wipe `{}` — your current directory lives inside it",
abs_target.display()
)));
}
}
}
std::fs::remove_dir_all(path).map_err(Error::Io)?;
}
layout.create_layout()?;
let config_path = layout.config_path();
std::fs::write(&config_path, DEFAULT_PROJECT_CONFIG)?;
info!(path = %config_path.display(), "wrote project config");
let prompts_path = layout.root.join(PROMPTS_FILE_DEFAULT);
std::fs::write(&prompts_path, DEFAULT_PROMPTS)?;
info!(path = %prompts_path.display(), "wrote prompt library");
// Round-trip parse the config to validate it.
let cfg = Config::load(&config_path)?;
// Open the document store. This creates `metadata.db` + `vecstore/`.
// First-run embedding-model download (if needed) happens here.
let store = Store::open(layout.clone(), &cfg)?;
// 1.2.6+ — seed the Prompts book with `<name>.example`
// paragraphs carrying every embedded default prompt
// inkhaven knows about (F7 grammar-check, F11 explain-
// diagnostic, F12 critique-edit + critique-changes). The
// user reviews / tunes the body, then renames to drop the
// `.example` suffix to take effect — without that suffix,
// inkhaven keeps using the built-in default. Gated on
// `ai.reseed_prompt_examples` (default true).
if cfg.ai.reseed_prompt_examples {
if let Err(e) = seed_prompt_examples(&cfg, &store) {
// Non-fatal — the user can `inkhaven add ¶` these
// later if seeding hiccups for any reason.
tracing::warn!(
target: "inkhaven::init",
"could not seed Prompts.book examples: {e}",
);
}
}
eprintln!("Initialized inkhaven project at {}", layout.root.display());
eprintln!(" config: {}", layout.config_path().display());
eprintln!(" prompts: {}", layout.root.join(PROMPTS_FILE_DEFAULT).display());
eprintln!(" store db: {}", layout.metadata_db_path().display());
eprintln!(" vecstore: {}", layout.vecstore_path().display());
eprintln!(" books: {}", layout.books_path().display());
// 1.2.14+ Phase Q.1 — apply the named template
// AFTER the standard init. Errors here surface
// upward but don't roll back the standard init
// (a partial template scaffold is recoverable
// via `inkhaven add`; a rolled-back init isn't).
if !template.eq_ignore_ascii_case("empty") {
eprintln!();
eprintln!("Applying template `{template}`:");
super::templates::apply(&store, &cfg, template)?;
}
Ok(())
}
/// 1.2.6+ — seed every embedded prompt as a `<name>.example`
/// paragraph in the Prompts system book. The paragraph body is
/// the embedded fallback prompt verbatim, preceded by a short
/// `// ` Typst-comment intro that explains the lookup rule. The
/// user reviews, tunes, then renames the paragraph to drop the
/// `.example` suffix — at that point the resolver picks it up
/// and the F-key uses the user's prompt instead of the
/// embedded default.
pub(crate) fn seed_prompt_examples(cfg: &Config, store: &Store) -> Result<()> {
use crate::store::hierarchy::Hierarchy;
use crate::store::{
InsertPosition, NodeKind, SYSTEM_TAG_PROMPTS,
};
// 1.2.12+ Phase B — embedded prompts are now keyed by
// ISO 639-1 (`en`/`ru`/`es`/`de`/`fr`). Map the long-
// form `cfg.language` once here so the seeds carry the
// project's working language out of the box.
let lang_iso = crate::ai::prompts::iso_from_long(&cfg.language);
// (paragraph_title, body) tuples. Title carries the `.example`
// suffix so it's clearly inert until the user renames.
let seeds: [(&str, String); 5] = [
(
"grammar-check.example",
format!(
"// F7 — grammar check the open paragraph.\n\
// Rename this paragraph to `grammar-check` (drop `.example`)\n\
// to take effect; until then inkhaven uses the built-in default.\n\n\
{}\n",
crate::tui::app::grammar_check_default_prompt(lang_iso),
),
),
(
"explain-diagnostic.example",
format!(
"// Ctrl+F12 — AI-explain the typst diagnostic at the cursor.\n\
// Rename to `explain-diagnostic` to take effect.\n\n\
{}\n",
crate::tui::app::explain_diagnostic_default_prompt(lang_iso),
),
),
(
"critique-edit.example",
format!(
"// F12 (editor mode) — what's weak about the open paragraph.\n\
// Rename to `critique-edit` to take effect.\n\n\
{}\n",
crate::tui::app::critique_edit_default_prompt(lang_iso),
),
),
(
"critique-changes.example",
format!(
"// F12 (split-edit mode) — evaluate the changes from the snapshot.\n\
// Rename to `critique-changes` to take effect.\n\n\
{}\n",
crate::tui::app::critique_changes_default_prompt(lang_iso),
),
),
(
"timeline-health.example",
format!(
"// 1.2.6+ — Ctrl+V t · y/Y/Ctrl+Y · timeline\n\
// consistency audit. Rename to `timeline-health`\n\
// to take effect.\n\n\
{}\n",
crate::tui::app::timeline_health_default_prompt(lang_iso),
),
),
];
let hierarchy = Hierarchy::load(store)?;
// Find the Prompts system book.
let prompts_book = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(SYSTEM_TAG_PROMPTS)
})
.cloned()
.ok_or_else(|| {
Error::Store("Prompts system book missing after Store::open".into())
})?;
for (title, body) in &seeds {
// Reload hierarchy each pass so subsequent lookups see
// freshly-added siblings (mirrors the typst-skeleton
// seeding pattern in store/mod.rs).
let h = Hierarchy::load(store)?;
let already = h.iter().any(|n| {
n.kind == NodeKind::Paragraph
&& n.parent_id == Some(prompts_book.id)
&& n.title.eq_ignore_ascii_case(title)
});
if already {
continue;
}
let mut created = store.create_node(
cfg,
&h,
NodeKind::Paragraph,
title,
Some(&prompts_book),
None,
InsertPosition::End,
)?;
// Overwrite the auto-`= Title\n\n` skeleton with the
// embedded prompt.
if let Some(rel) = &created.file {
let abs = store.project_root().join(rel);
std::fs::write(&abs, body.as_bytes()).map_err(Error::Io)?;
store.update_paragraph_content(&mut created, body.as_bytes())?;
}
}
Ok(())
}
/// Interactive y/N prompt on stderr. Returns true only when the user types
/// `y` / `yes` (case-insensitive). Any other input — including an empty
/// line, EOF, or `n` — returns false so we never wipe by accident.
fn confirm_overwrite(path: &Path) -> Result<bool> {
eprint!(
"Directory `{}` already exists. Remove it and re-initialise? [y/N] ",
path.display()
);
io::stderr().flush().ok();
let mut buf = String::new();
if io::stdin().read_line(&mut buf).map_err(Error::Io)? == 0 {
return Ok(false);
}
let answer = buf.trim().to_ascii_lowercase();
Ok(matches!(answer.as_str(), "y" | "yes"))
}