mindfork 0.10.1

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
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
//! Schema versions of saved data and a **pure JSON-migration framework**.
//!
//! The single home for the schema-version constants (per artifact: settings/profiles/
//! chats/DB change at different rates, one global number would force "migrating"
//! untouched files). Only Value-level logic lives here (version detection, running
//! steps); file I/O, backups, and control-parsing into typed structs live in
//! [`crate::features::data_migration`] (orchestration; `shared` cannot depend on
//! `features`, and the pre-migration backup lives in `features::backup`). See
//! [docs/history/release-engineering.md](../../../docs/history/release-engineering.md) §3.4 and ADR 0006.
//!
//! **Bump policy** (release-engineering.md F12): an additive change (a new field with
//! `#[serde(default)]`, a new table/column with a default) — **no bump**, as before;
//! a breaking one (renaming/moving/changing semantics/removing a field) — bump the
//! constant + a migration step + a golden fixture of the old format + a CHANGELOG
//! entry (the "Data" rubric).

use std::cmp::Ordering;

use anyhow::{Context, Result};
use serde_json::Value;

/// Schema version of `settings.json`. Matches [`crate::shared::config::SCHEMA_VERSION`]
/// (the default of the `AppConfig.schema_version` field) — the invariant is checked by
/// a test.
pub const SETTINGS_SCHEMA: u32 = 3;
/// Schema version of `profiles.json`.
pub const PROFILES_SCHEMA: u32 = 1;
/// Schema version of a chat file `chats/<id>.json`.
pub const CHAT_SCHEMA: u32 = 4;
/// SQLite schema version (`PRAGMA user_version`). The DB migration runner is in
/// [`crate::shared::storage::db`] (baseline 0→1 + steps in transactions).
pub const DB_SCHEMA: u32 = 1;

/// A JSON migration step: a pure transformation "version `< to` → `to`".
pub struct Step {
    /// The target version this step produces.
    pub to: u32,
    /// A short description for the migration log (not user-facing text).
    pub summary: &'static str,
    /// The value transformation. Must be pure (no I/O).
    pub apply: fn(Value) -> Result<Value>,
}

/// Description of a versioned JSON artifact: how to detect a value's version and the
/// chain of steps to the current one. The format doesn't change today (all schemas = 1,
/// `steps` are empty) — the framework is "in production" on empty migrations; the first
/// real migration will add a step + a fixture.
pub struct JsonArtifact {
    /// Name for logging/errors (`"settings.json"`).
    pub name: &'static str,
    /// The current (target) schema version.
    pub current: u32,
    /// Detects a value's version structurally (existing files aren't rewritten:
    /// "no version field → 1").
    pub detect: fn(&Value) -> u32,
    /// Steps `1→2→…→current`, in ascending order of `to`.
    pub steps: &'static [Step],
}

/// Verdict on a value's version relative to the artifact's current schema.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assessment {
    /// The version matches the current one — no migration needed.
    UpToDate,
    /// The version is older — steps `from → current` need to run.
    Migrate { from: u32 },
    /// The version is **newer** than the app (data from a newer mindfork version) —
    /// migration is impossible; the caller refuses to start (release-engineering.md F10).
    Downgrade { from: u32 },
}

impl JsonArtifact {
    /// Detects a value's version and compares it to the current one.
    pub fn assess(&self, v: &Value) -> Assessment {
        let from = (self.detect)(v);
        match from.cmp(&self.current) {
            Ordering::Equal => Assessment::UpToDate,
            Ordering::Less => Assessment::Migrate { from },
            Ordering::Greater => Assessment::Downgrade { from },
        }
    }

    /// Runs the steps `from → current`. The result is not yet validated by a typed
    /// parse — the orchestrator does that (control-parse before writing).
    pub fn apply_steps(&self, mut v: Value, from: u32) -> Result<Value> {
        for step in self.steps.iter().filter(|s| s.to > from) {
            tracing::info!(
                artifact = self.name,
                to = step.to,
                "migration step: {}",
                step.summary
            );
            v = (step.apply)(v)
                .with_context(|| format!("migration step {} → v{}", self.name, step.to))?;
        }
        Ok(v)
    }
}

/// `settings.json` version — by the `schema_version` field (absent → 1).
fn detect_settings(v: &Value) -> u32 {
    v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}

/// `profiles.json` version — historically a bare array (→ 1); an envelope shape with a
/// `schema_version` field will appear at the first breaking change.
fn detect_profiles(v: &Value) -> u32 {
    if v.is_array() {
        1
    } else {
        v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
    }
}

/// Chat-file version — by the `v` field (absent → 1). The field was not written
/// while the schema stayed at 1; since 2 every save writes it (`Chat::v`), so a
/// migrated file is never detected as 1 again.
fn detect_chat(v: &Value) -> u32 {
    v.get("v").and_then(Value::as_u64).unwrap_or(1) as u32
}

/// The values `settings.json` v1 wrote for the sub-agent knobs by default —
/// frozen here, because a migration step describes the past and must not follow
/// the constants in `config.rs` when those move again.
const V1_SUBAGENT_TIMEOUT_SECS: u64 = 60;
const V1_SUBAGENT_MAX_TOKENS: u64 = 1024;

/// `settings.json` 1→2: the sub-agent gained tools (spec §9.3.2,
/// docs/research/subagent-chats.md §3.12), so its one-request timeout
/// `tools.subagent_timeout_secs` became the whole-run `subagent_run_timeout_secs`
/// with a default ten times larger, and its per-round reply cap's default rose.
/// A value the user left at the old default is **dropped** (the new default
/// applies on read — `ToolSettings` is `#[serde(default)]`); a value the user
/// changed is carried over under the new name, because a number somebody typed
/// is a decision. Nothing else in the file is touched.
fn settings_to_v2(mut v: Value) -> Result<Value> {
    if let Some(tools) = v.get_mut("tools").and_then(Value::as_object_mut) {
        if let Some(old) = tools.remove("subagent_timeout_secs")
            && old.as_u64() != Some(V1_SUBAGENT_TIMEOUT_SECS)
        {
            tools.insert("subagent_run_timeout_secs".into(), old);
        }
        if tools.get("subagent_max_tokens").and_then(Value::as_u64) == Some(V1_SUBAGENT_MAX_TOKENS)
        {
            tools.remove("subagent_max_tokens");
        }
    }
    v["schema_version"] = Value::from(2);
    Ok(v)
}

/// The reply budget `settings.json` v2 wrote by default, and the one v3 writes —
/// both frozen here for the same reason as the constants above: a migration step
/// describes one transition, and must not move when today's defaults do.
const V2_MAX_TOKENS: u64 = 2048;
const V3_MAX_TOKENS: u64 = 16384;

/// `settings.json` 2→3: the default reply budget rose from 2048 to 16384, because
/// that number covers the model's **reasoning** as well on every provider that
/// bills it that way, and 2048 was set before any of them did. Measured on
/// `gemini-2.5-pro` with thinking on: 1697 tokens of thinking left 347 for the
/// answer and the reply came back cut (`MAX_TOKENS`); the same question at 16384
/// finished on its own (docs/research/robustness-and-defaults.md F5).
///
/// A value left at the old default is **rewritten** to the new one; a value the
/// user typed is untouched, because a number somebody chose is a decision — the
/// same rule the 1→2 step follows. Nothing else in the file changes.
///
/// Rewritten rather than dropped, which is what the 1→2 step does with its
/// numbers, and the difference is not cosmetic: `ToolSettings::default()` carries
/// its constant, so a removed key lands on the new default, while
/// `SamplingConfig::default()` has `max_tokens: None` — the 16384 lives in
/// `AppConfig::default()`, which serde never consults for a section that is
/// present. Dropping the key here would therefore have meant *no limit at all*,
/// which is a different decision than the one being made. A test asserts the
/// parsed outcome, not the JSON, for exactly this reason.
fn settings_to_v3(mut v: Value) -> Result<Value> {
    if let Some(sampling) = v.get_mut("default_sampling").and_then(Value::as_object_mut)
        && sampling.get("max_tokens").and_then(Value::as_u64) == Some(V2_MAX_TOKENS)
    {
        sampling.insert("max_tokens".into(), Value::from(V3_MAX_TOKENS));
    }
    v["schema_version"] = Value::from(3);
    Ok(v)
}

const SETTINGS_STEPS: &[Step] = &[
    Step {
        to: 2,
        summary: "the sub-agent's one-request timeout becomes a whole-run limit",
        apply: settings_to_v2,
    },
    Step {
        to: 3,
        summary: "the default reply budget rises to cover a model's reasoning",
        apply: settings_to_v3,
    },
];

/// The registry of artifacts. `settings.json` is at 3 (two steps), the chat files
/// at 4; `profiles.json` is still at 1 with no steps.
pub fn settings_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "settings.json",
        current: SETTINGS_SCHEMA,
        detect: detect_settings,
        steps: SETTINGS_STEPS,
    }
}

pub fn profiles_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "profiles.json",
        current: PROFILES_SCHEMA,
        detect: detect_profiles,
        steps: &[],
    }
}

const CHAT_STEPS: &[Step] = &[
    Step {
        to: 2,
        summary: "a transcript is synthesized for every old call_subagent record",
        apply: super::chat_steps::chat_to_v2,
    },
    Step {
        to: 3,
        summary: "chat files may carry dialogue runs (RunKind::Dialogue)",
        apply: super::chat_steps::chat_to_v3,
    },
    Step {
        to: 4,
        summary: "a run title the old 100-character cap cut gets its tail back",
        apply: super::chat_steps::chat_to_v4,
    },
];

pub fn chat_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "chats/<id>.json",
        current: CHAT_SCHEMA,
        detect: detect_chat,
        steps: CHAT_STEPS,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn schema_constants_match_config_default() {
        // Invariant: the default of `AppConfig.schema_version` and settings' current stay in sync.
        assert_eq!(SETTINGS_SCHEMA, crate::shared::config::SCHEMA_VERSION);
    }

    #[test]
    fn detect_uses_field_or_defaults_to_one() {
        assert_eq!(detect_settings(&json!({"schema_version": 3})), 3);
        assert_eq!(detect_settings(&json!({})), 1);
        assert_eq!(detect_profiles(&json!([])), 1); // a bare array = v1
        assert_eq!(detect_profiles(&json!({"schema_version": 2})), 2);
        assert_eq!(detect_chat(&json!({"v": 5})), 5);
        assert_eq!(detect_chat(&json!({"title": "x"})), 1);
    }

    // A synthetic "current = 2" artifact with a 1→2 step — checks the engine separately
    // from the real registry (where all schemas = 1 and there are no steps).
    fn to_v2(mut v: Value) -> Result<Value> {
        v["schema_version"] = json!(2);
        Ok(v)
    }
    const SYNTH_STEPS: &[Step] = &[Step {
        to: 2,
        summary: "test bump",
        apply: to_v2,
    }];
    fn synth() -> JsonArtifact {
        JsonArtifact {
            name: "synthetic",
            current: 2,
            detect: detect_settings,
            steps: SYNTH_STEPS,
        }
    }

    #[test]
    fn assess_classifies_up_to_date_migrate_downgrade() {
        let a = synth();
        assert_eq!(
            a.assess(&json!({"schema_version": 2})),
            Assessment::UpToDate
        );
        assert_eq!(
            a.assess(&json!({"schema_version": 1})),
            Assessment::Migrate { from: 1 }
        );
        assert_eq!(a.assess(&json!({})), Assessment::Migrate { from: 1 });
        assert_eq!(
            a.assess(&json!({"schema_version": 3})),
            Assessment::Downgrade { from: 3 }
        );
    }

    #[test]
    fn apply_steps_runs_only_needed_steps() {
        let a = synth();
        let out = a
            .apply_steps(json!({"schema_version": 1, "x": 7}), 1)
            .unwrap();
        assert_eq!(out["schema_version"], json!(2));
        assert_eq!(out["x"], json!(7), "other fields are preserved");
        // from == current → no steps run (the value stays unchanged).
        let noop = a.apply_steps(json!({"schema_version": 2}), 2).unwrap();
        assert_eq!(noop["schema_version"], json!(2));
    }

    #[test]
    fn apply_step_error_propagates() {
        fn boom(_v: Value) -> Result<Value> {
            anyhow::bail!("broken")
        }
        const STEPS: &[Step] = &[Step {
            to: 2,
            summary: "boom",
            apply: boom,
        }];
        let a = JsonArtifact {
            name: "x",
            current: 2,
            detect: detect_settings,
            steps: STEPS,
        };
        assert!(a.apply_steps(json!({}), 1).is_err());
    }

    #[test]
    fn real_registry_versions_and_steps() {
        // Settings took two real steps (the sub-agent's timeout, then the reply
        // budget that has to cover reasoning); chats took three (the dialogue-run
        // stamp, spec §9.13, then the cut run titles, spec §11.2); profiles
        // are still dormant.
        let settings = settings_artifact();
        assert_eq!(settings.current, 3);
        assert_eq!(settings.steps.len(), 2);
        assert_eq!(settings.steps[0].to, 2);
        assert_eq!(settings.steps[1].to, 3);
        let chats = chat_artifact();
        assert_eq!(chats.current, 4);
        assert_eq!(chats.steps.len(), 3);
        assert_eq!(chats.steps[0].to, 2);
        assert_eq!(chats.steps[1].to, 3);
        assert_eq!(chats.steps[2].to, 4);
        let profiles = profiles_artifact();
        assert_eq!(profiles.current, 1);
        assert!(profiles.steps.is_empty());
    }

    /// The golden v1 shape: the two sub-agent knobs under the names and
    /// defaults `settings.json` carried before the step, beside fields the step
    /// must leave alone.
    fn v1_settings(timeout: u64, max_tokens: u64) -> Value {
        json!({
            "schema_version": 1,
            "max_tool_rounds": 8,
            "tools": {
                "web_enabled": true,
                "subagent_max_tokens": max_tokens,
                "subagent_timeout_secs": timeout,
                "confirm_dangerous": false
            }
        })
    }

    #[test]
    fn settings_step_drops_old_defaults_so_the_new_ones_apply() {
        let out = settings_artifact()
            .apply_steps(v1_settings(60, 1024), 1)
            .unwrap();
        assert_eq!(out["schema_version"], json!(3));
        let tools = out["tools"].as_object().unwrap();
        assert!(!tools.contains_key("subagent_timeout_secs"));
        assert!(!tools.contains_key("subagent_run_timeout_secs"));
        assert!(!tools.contains_key("subagent_max_tokens"));
        // Untouched neighbours.
        assert_eq!(tools["web_enabled"], json!(true));
        assert_eq!(tools["confirm_dangerous"], json!(false));
        assert_eq!(out["max_tool_rounds"], json!(8));
        // And the migrated value parses into today's config with today's defaults.
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
        assert_eq!(
            cfg.tools.subagent_run_timeout_secs,
            crate::shared::config::DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS
        );
        assert_eq!(
            cfg.tools.subagent_max_tokens,
            crate::shared::config::DEFAULT_SUBAGENT_MAX_TOKENS
        );
    }

    #[test]
    fn settings_step_carries_a_changed_value_under_the_new_name() {
        let out = settings_artifact()
            .apply_steps(v1_settings(120, 2048), 1)
            .unwrap();
        let tools = out["tools"].as_object().unwrap();
        assert!(!tools.contains_key("subagent_timeout_secs"));
        assert_eq!(tools["subagent_run_timeout_secs"], json!(120));
        assert_eq!(tools["subagent_max_tokens"], json!(2048));
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
        assert_eq!(cfg.tools.subagent_run_timeout_secs, 120);
        assert_eq!(cfg.tools.subagent_max_tokens, 2048);
    }

    #[test]
    fn settings_step_tolerates_a_file_without_the_tools_section() {
        let out = settings_artifact()
            .apply_steps(json!({"schema_version": 1}), 1)
            .unwrap();
        assert_eq!(out["schema_version"], json!(3));
    }

    /// 2→3: a reply budget left at the old default is dropped so the new one
    /// applies, and one the user typed is kept — a number somebody chose is a
    /// decision (docs/research/robustness-and-defaults.md F5).
    #[test]
    fn settings_step_lifts_the_untouched_reply_budget_and_keeps_a_chosen_one() {
        let at_the_old_default = settings_artifact()
            .apply_steps(
                json!({"schema_version": 2, "default_sampling": {"max_tokens": 2048, "thinking": true}}),
                2,
            )
            .unwrap();
        assert_eq!(at_the_old_default["schema_version"], json!(3));
        let sampling = at_the_old_default["default_sampling"].as_object().unwrap();
        assert_eq!(sampling["max_tokens"], json!(16384), "{sampling:?}");
        // The neighbour a user may well have turned off stays as it was.
        assert_eq!(sampling["thinking"], json!(true));
        let cfg: crate::shared::config::AppConfig =
            serde_json::from_value(at_the_old_default).unwrap();
        assert_eq!(
            cfg.default_sampling.max_tokens,
            crate::shared::config::AppConfig::default()
                .default_sampling
                .max_tokens
        );

        for chosen in [512u64, 4096, 65536] {
            let out = settings_artifact()
                .apply_steps(
                    json!({"schema_version": 2, "default_sampling": {"max_tokens": chosen}}),
                    2,
                )
                .unwrap();
            assert_eq!(
                out["default_sampling"]["max_tokens"],
                json!(chosen),
                "a typed {chosen} must survive"
            );
        }

        // A file that never wrote the section at all migrates without inventing one.
        let bare = settings_artifact()
            .apply_steps(json!({"schema_version": 2}), 2)
            .unwrap();
        assert_eq!(bare["schema_version"], json!(3));
        assert!(bare.get("default_sampling").is_none());
    }
}