Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ auto_save_conversations = true
history_retention_days = -1
# Set true after the one-shot auto-save chat notice is dismissed ("Acknowledge").
auto_save_notice_acknowledged = false
# Weights SHA-256 of models you chose to load past the mild "may not fit in
# memory" warning. Managed from the warning card and removable in Settings;
# freeze-risk loads still warn. Sanitized on load (64-hex only, deduped, capped).
dismissed_memory_fit_models = []

[debug]
# Records every chat conversation, including its built-in web-search turns, to
Expand Down Expand Up @@ -255,6 +259,16 @@ Controls rewrite/replace dismiss behavior and whether the built-in engine may op
| `auto_save_conversations` | `true` | Yes | — | — | On (default): each completed turn is written to local history without a bookmark click. Off: only an explicit Save persists the chat. Toggle from Settings › Behavior. |
| `history_retention_days` | `-1` | Yes | n/a | `-1` or `1`..`3650` | How many days saved chats are kept by last activity (`updated_at`) before a startup or confirmed retention-change prune deletes them. Raise to keep history longer; lower to reclaim disk sooner; `-1` to keep forever. `0` and other out-of-range values reset to the default. Changing to a finite window asks for confirmation before write + prune. |
| `auto_save_notice_acknowledged` | `false` | Yes | — | — | When false, after the first auto-saved turn Thuki can show a one-shot chat-header notice that chats are being saved. Set true on Acknowledge; the notice never returns. Independent of `auto_save_conversations`. |
| `dismissed_memory_fit_models` | `[]` | Yes | — | up to 64 entries | Weights SHA-256 of models you chose to load past the mild "may not fit in memory" warning ("Don't warn me about this model again"). A listed model skips the warning only in the mild over-limit band; a freeze-risk load (estimate at or above free memory) still warns. Managed from the warning card and removable per row in Settings › Behavior. Sanitized on load: only 64-char lowercase-hex entries are kept, duplicates are dropped, and the list is capped FIFO at 64. |

### Memory-fit gate

Loading a model estimates its resident footprint and compares it to the memory free right now, refusing an un-forced load that would not fit. The two fractions below bound that decision; both are baked-in safety constants, not preferences.

| Constant | Value | Tunable? | Why not tunable | Description |
| :------- | :---- | :------- | :-------------- | :---------- |
| `MODEL_FIT_HARD_BLOCK_FRACTION` | `3.00` | No | Defense-in-depth freeze bound | Freeze-band floor: at or above 3x free memory, the "may not fit" warning always fires and can never be suppressed by a per-model remember (`dismissed_memory_fit_models`), so the "Always allow this model" action is hidden and only the single force plus "Switch model" remain. A load needing at least triple the free memory is a gross over-commit that risks a non-pageable Metal wiring and a hard freeze. Below this floor a merely-over-the-ceiling load (up to 3x free RAM: the common "squeeze it out" case where the estimate is conservative and it usually still fits) can be remembered. A per-turn "Load once" still bypasses the floor; a persisted remember never does, because free RAM is dynamic. |
| `MAX_DISMISSED_MEMORY_FIT_MODELS` | `64` | No | Bound on a user-editable list | Cap on how many remembered memory-fit overrides are kept in config and memory. Adding beyond the cap evicts the oldest entry FIFO, so repeated opt-ins can never grow the list without bound. |

### Web search

Expand Down
49 changes: 44 additions & 5 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,15 @@ pub fn builtin_target(
/// rather than blocking a load on a database hiccup. Coverage-off: pure wiring
/// over tested functions; the gate logic lives in `models::memory`.
///
/// `dismissed_shas` is the caller's snapshot of
/// `behavior.dismissed_memory_fit_models` (read from managed config at the
/// command boundary). The target's weights SHA-256 comes from the same manifest
/// row that sizes it, so no extra lookup is needed; when that sha is in the
/// list, the model is treated as `dismissed` and
/// [`memory::apply_dismissed_override`] downgrades a MILD over-limit block to
/// Proceed. A freeze-band block is never downgraded, so the remember can never
/// defeat the guardrail on the dangerous case.
///
/// [`MemoryGate::Proceed`]: crate::models::memory::MemoryGate::Proceed
#[cfg_attr(coverage_nightly, coverage(off))]
pub(crate) fn preflight_memory_gate(
Expand All @@ -315,18 +324,23 @@ pub(crate) fn preflight_memory_gate(
model_id: &str,
target_path: &std::path::Path,
forced: bool,
dismissed_shas: &[String],
) -> crate::models::memory::MemoryGate {
use crate::models::memory;
// Read the live engine status once: it feeds both the already-loading bypass
// and the resident-credit path, both applied inside `decide_load_gate`.
let status = engine.current_status();
// Cannot size the target -> do not block on a database hiccup. Fold mmproj
// blob length (or registry hint) when present so vision loads estimate true
// footprint.
let target_weights = match crate::models::manifest::get(conn, model_id) {
// footprint. The same row carries the weights SHA-256 that keys the
// remembered-override list, so no extra lookup is needed.
let (target_weights, dismissed) = match crate::models::manifest::get(conn, model_id) {
Ok(Some(row)) => {
let mm = crate::models::memory::mmproj_bytes_for_model(store, &row);
memory::model_load_bytes(&row, mm)
(
memory::model_load_bytes(&row, mm),
memory::is_model_remembered(&row.sha256, dismissed_shas),
)
}
_ => return memory::MemoryGate::Proceed,
};
Expand All @@ -350,7 +364,7 @@ pub(crate) fn preflight_memory_gate(
// Single source of the block decision, shared with `estimate_model_fit`'s
// `build_model_fit_estimate` so the gate and the frontend fit affordance can
// never diverge (issue #296). It folds in the already-loading bypass.
memory::decide_load_gate(
let gate = memory::decide_load_gate(
&status.state,
&status.model_path,
target_weights,
Expand All @@ -359,7 +373,10 @@ pub(crate) fn preflight_memory_gate(
target_path,
&installed,
forced,
)
);
// Apply the per-model remember: a mild over-limit block for a dismissed
// model becomes Proceed, while a freeze-band block always stands.
memory::apply_dismissed_override(gate, dismissed)
}

/// Parses llama-server's `GET /props` response and reports whether the
Expand Down Expand Up @@ -1669,6 +1686,11 @@ pub enum TransportError {
/// swallow. When the model fits, or the exact model is already resident, the
/// load proceeds exactly as before. Non-builtin routes ignore `policy`.
///
/// `dismissed_shas` is the caller's snapshot of the remembered memory-fit
/// overrides (`behavior.dismissed_memory_fit_models`), threaded to
/// [`preflight_memory_gate`] on the builtin arm so a mild over-limit load of a
/// remembered model proceeds silently while a freeze-band load still refuses.
///
/// [`Target`]: crate::engine::state::Target
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resolve_llm_transport(
Expand All @@ -1680,6 +1702,7 @@ pub(crate) async fn resolve_llm_transport(
num_ctx: u32,
cancel_token: &CancellationToken,
policy: OversizePolicy,
dismissed_shas: &[String],
) -> Result<LlmTransport, TransportError> {
match route {
ChatRoute::OllamaNative { endpoint } => Ok(LlmTransport::OllamaNative { endpoint }),
Expand Down Expand Up @@ -1713,6 +1736,7 @@ pub(crate) async fn resolve_llm_transport(
&model_id,
&target.model_path,
forced,
dismissed_shas,
);
(target, gate)
};
Expand Down Expand Up @@ -2538,6 +2562,7 @@ pub async fn ask_model(
&model_id,
&target.model_path,
allow_oversized == Some(true),
&config.behavior.dismissed_memory_fit_models,
);
(target, gate)
},
Expand Down Expand Up @@ -6294,6 +6319,7 @@ mod tests {
// Non-builtin route: the memory gate never runs, so the policy is
// irrelevant here.
OversizePolicy::Block { forced: false },
&[],
)
.await
.unwrap();
Expand Down Expand Up @@ -6331,6 +6357,7 @@ mod tests {
&CancellationToken::new(),
// Non-builtin route: the memory gate never runs.
OversizePolicy::Block { forced: false },
&[],
)
.await
.unwrap();
Expand Down Expand Up @@ -6376,6 +6403,7 @@ mod tests {
// Force past the memory gate: this test exercises the ensure path,
// orthogonal to the gate (covered separately below).
OversizePolicy::Block { forced: true },
&[],
)
.await
.unwrap();
Expand Down Expand Up @@ -6415,6 +6443,7 @@ mod tests {
// `builtin_target` errors on the missing row before the gate runs,
// so the policy is irrelevant.
OversizePolicy::Block { forced: false },
&[],
)
.await
.unwrap_err();
Expand Down Expand Up @@ -6463,6 +6492,7 @@ mod tests {
&CancellationToken::new(),
// Force past the gate: this test exercises poisoned-lock recovery.
OversizePolicy::Block { forced: true },
&[],
)
.await
.unwrap();
Expand Down Expand Up @@ -6509,6 +6539,7 @@ mod tests {
// Force past the gate: this asserts the spawn StartFailed mapping,
// which a gate Block would preempt on a low-memory runner.
OversizePolicy::Block { forced: true },
&[],
)
.await
.unwrap_err();
Expand Down Expand Up @@ -6553,6 +6584,7 @@ mod tests {
// Force past the gate: this asserts the unload-preempt
// Superseded mapping, orthogonal to the memory gate.
OversizePolicy::Block { forced: true },
&[],
)
.await
})
Expand Down Expand Up @@ -6608,6 +6640,7 @@ mod tests {
// Force past the gate: this asserts the cancel-during-ensure
// mapping, orthogonal to the memory gate.
OversizePolicy::Block { forced: true },
&[],
)
.await
})
Expand Down Expand Up @@ -6665,6 +6698,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::Block { forced: false },
&[],
)
.await
.unwrap_err();
Expand Down Expand Up @@ -6704,6 +6738,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::Block { forced: true },
&[],
)
.await
.unwrap();
Expand Down Expand Up @@ -6747,6 +6782,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::SilentSkip,
&[],
)
.await
.unwrap_err();
Expand Down Expand Up @@ -6788,6 +6824,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::Block { forced: true },
&[],
)
.await
.unwrap();
Expand All @@ -6807,6 +6844,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::Block { forced: false },
&[],
)
.await
.unwrap();
Expand All @@ -6821,6 +6859,7 @@ mod tests {
DEFAULT_NUM_CTX,
&CancellationToken::new(),
OversizePolicy::SilentSkip,
&[],
)
.await
.unwrap();
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/config/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,32 @@ pub const MODEL_FIT_COMFORT_FRACTION: f64 = 0.60;
/// only clearly above the ceiling and a user `force` always bypasses it.
pub const MODEL_FIT_CEILING_FRACTION: f64 = 0.80;

/// Freeze-band floor: the fit ratio (estimated footprint over live available
/// memory) at or above which the memory warning ALWAYS fires and can never be
/// suppressed by a per-model "remember" override. At `3.00` the estimate needs
/// at least triple the memory the machine can hand out right now, a gross
/// over-commit that would force macOS to wire non-pageable Metal memory it does
/// not have, risking a hard freeze and a reboot. Below this floor a load merely
/// over the mild ceiling (needing up to 3x free RAM: the common "squeeze it
/// out" case where the estimate is conservative and it usually still fits) is
/// offered the per-model "Always allow this model" override, so the user is not
/// re-warned. A per-turn `force` ("load anyway") still bypasses this floor, but
/// a persisted remember never does: free RAM is dynamic, so a model that fit
/// when RAM was free must re-warn if RAM later drops it into this band.
///
/// Not user-tunable: a defense-in-depth freeze bound on the most dangerous
/// load, not a preference.
pub const MODEL_FIT_HARD_BLOCK_FRACTION: f64 = 3.00;

/// Cap on the number of remembered memory-fit overrides
/// (`behavior.dismissed_memory_fit_models`) kept in config and memory. Beyond
/// this the oldest entry is evicted FIFO when a new one is added, so the list
/// can never grow without bound from repeated opt-ins.
///
/// Not user-tunable: a defense-in-depth bound on a user-editable list, not a
/// preference.
pub const MAX_DISMISSED_MEMORY_FIT_MODELS: usize = 64;

/// Maximum accepted byte length for a Hugging Face search query before it is
/// sent upstream. Defense-in-depth bound on attacker-influenced input: the
/// query reaches the fixed Hub host (no SSRF) and is percent-encoded by the
Expand Down
77 changes: 75 additions & 2 deletions src-tauri/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
//! fatal (the app cannot boot in a writable-hostile environment and the user
//! cannot fix that from the UI).

use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

Expand All @@ -33,8 +34,8 @@ use super::defaults::{
DEFAULT_QUOTE_MAX_DISPLAY_LINES, DEFAULT_SYSTEM_PROMPT_BASE, DEFAULT_TEXT_BASE_PX,
DEFAULT_TEXT_FONT_WEIGHT, DEFAULT_TEXT_LETTER_SPACING_PX, DEFAULT_TEXT_LINE_HEIGHT,
DEFAULT_TRACE_RETENTION_DAYS, DEFAULT_UPDATER_CHECK_INTERVAL_HOURS,
DEFAULT_UPDATER_MANIFEST_URL, HISTORY_RETENTION_FOREVER, SLASH_COMMAND_PROMPT_APPENDIX,
TRACE_RETENTION_FOREVER,
DEFAULT_UPDATER_MANIFEST_URL, HISTORY_RETENTION_FOREVER, MAX_DISMISSED_MEMORY_FIT_MODELS,
SLASH_COMMAND_PROMPT_APPENDIX, TRACE_RETENTION_FOREVER,
};
use super::error::ConfigError;
use super::schema::AppConfig;
Expand Down Expand Up @@ -222,6 +223,12 @@ pub(crate) fn resolve(config: &mut AppConfig) {
BOUNDS_HISTORY_RETENTION_DAYS,
"behavior.history_retention_days",
);
// The remembered memory-fit override list is user-editable TOML: drop any
// entry that is not a well-formed weights SHA-256, dedupe, and cap the
// length so a hand-edited file can never inject garbage or grow unbounded.
config.behavior.dismissed_memory_fit_models = sanitize_dismissed_memory_fit_models(
std::mem::take(&mut config.behavior.dismissed_memory_fit_models),
);

// Debug section: trace_enabled is a boolean (any value valid); the
// retention window is clamped to its sentinel-or-range contract.
Expand Down Expand Up @@ -487,6 +494,72 @@ fn clamp_font_weight(value: &mut u32, default: u32, field: &str) {
}
}

/// True when `sha` is a well-formed weights SHA-256 blob id: exactly 64
/// lowercase hexadecimal characters. The content-addressed blob store keys its
/// files on this digest, so only strings that could name a real blob are
/// accepted; anything else (wrong length, uppercase, non-hex) is rejected. Used
/// both to sanitize the loaded list and to normalize an id before it is added.
pub(crate) fn is_valid_weights_sha(sha: &str) -> bool {
sha.len() == 64
&& sha
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}

/// Sanitizes a user-supplied `dismissed_memory_fit_models` list: keeps only
/// entries that are well-formed weights SHA-256 ids (see [`is_valid_weights_sha`]),
/// removes duplicates preserving the first occurrence, then caps the length to
/// [`MAX_DISMISSED_MEMORY_FIT_MODELS`] by dropping the oldest (front) entries so
/// the most recently added survive. Never panics: any malformed input simply
/// yields a shorter (possibly empty) list.
///
/// Dedupe goes through a hash set rather than a linear scan of what has been
/// kept: this list is read straight off a hand-editable TOML file, and a linear
/// membership test would make a pathological file cost O(n^2) string
/// comparisons on the startup path. The cap is enforced inside the loop so the
/// retained queue never grows past it, whatever the file holds.
pub(crate) fn sanitize_dismissed_memory_fit_models(raw: Vec<String>) -> Vec<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut kept: VecDeque<String> = VecDeque::with_capacity(MAX_DISMISSED_MEMORY_FIT_MODELS);
for entry in raw {
// `insert` returning false means this digest was already accepted
// earlier, so the first occurrence is the one that counts.
if !is_valid_weights_sha(&entry) || !seen.insert(entry.clone()) {
continue;
}
kept.push_back(entry);
// Cap keeping the most recent: drop from the front (oldest) so a list
// that grew past the cap evicts FIFO. `seen` deliberately keeps the
// evicted digest, so a later duplicate of it does not resurrect the
// entry and the result matches a dedupe-then-truncate pass exactly.
if kept.len() > MAX_DISMISSED_MEMORY_FIT_MODELS {
kept.pop_front();
}
}
kept.into()
}

/// Returns `existing` with `sha` appended as a remembered memory-fit override,
/// then re-sanitized (see [`sanitize_dismissed_memory_fit_models`]) so the add
/// is idempotent (a sha already present is not duplicated) and the list stays
/// capped FIFO. The incoming `sha` is lowercased and trimmed first so a caller
/// passing an upper-case or padded digest still lands a valid entry; a `sha`
/// that is not a valid weights digest is dropped by the sanitize pass and
/// leaves the list unchanged. Pure and unit-tested.
pub(crate) fn with_dismissed_model_added(mut existing: Vec<String>, sha: &str) -> Vec<String> {
existing.push(sha.trim().to_ascii_lowercase());
sanitize_dismissed_memory_fit_models(existing)
}

/// Returns `existing` with every entry equal to `sha` removed (keyed by the
/// digest so an orphaned entry whose model is no longer installed is still
/// removable). The incoming `sha` is lowercased and trimmed to match the stored
/// form. Pure and unit-tested.
pub(crate) fn with_dismissed_model_removed(existing: Vec<String>, sha: &str) -> Vec<String> {
let target = sha.trim().to_ascii_lowercase();
existing.into_iter().filter(|s| *s != target).collect()
}

fn clamp_u32(value: &mut u32, bounds: (u32, u32), default: u32, field: &str) {
if !(bounds.0..=bounds.1).contains(value) {
eprintln!(
Expand Down
Loading