From 0474c6aafa6004e7a5ccae115c434303d0337a04 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Thu, 11 Jun 2026 16:04:41 -0500 Subject: [PATCH] =?UTF-8?q?feat(config):=20Phase=202=20Configurable=20Outp?= =?UTF-8?q?ut=20=E2=80=94=20vestige.toml=20+=20output=20profiles=20(v2.1.2?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional, local-first `vestige.toml` config file (loaded from the active data directory alongside vestige.db) that lets users control the default shape and size of high-traffic MCP responses without recompiling and without a cloud service. - New `vestige-core::config` module: `VestigeConfig` / `OutputConfig` / `OutputProfile` with a dependency-free, lenient minimal-TOML parser for the `[defaults]` table (detail_level, limit, profile). - Output profiles: lean | default | audit | research. `default` reproduces pre-2.1.24 behavior byte-for-byte so existing users see no change. - Precedence (per call): explicit MCP param > config file > built-in default. - Wired into search, memory_timeline, codebase (get_context), session_context. Each echoes the active `profile`; `lean` masks scores/timestamps via a shared `apply_output_masks` helper. - McpServer loads config once at construction from storage.data_dir(). - Tests: config precedence/profile unit tests in core (10) + per-tool precedence and lean-masking tests. Full workspace suite green, clippy -D warnings clean, dashboard check + build green. - Docs: new "Output Configuration (vestige.toml)" section in docs/CONFIGURATION.md and CHANGELOG 2.1.24 entry. Version bumped 2.1.23 -> 2.1.24. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 44 ++ Cargo.lock | 4 +- Cargo.toml | 2 +- apps/dashboard/package.json | 2 +- crates/vestige-core/Cargo.toml | 2 +- crates/vestige-core/src/config.rs | 388 ++++++++++++++++++ crates/vestige-core/src/lib.rs | 5 + crates/vestige-mcp/Cargo.toml | 4 +- crates/vestige-mcp/src/server.rs | 82 +++- .../vestige-mcp/src/tools/codebase_unified.rs | 71 +++- .../vestige-mcp/src/tools/search_unified.rs | 194 +++++++-- .../vestige-mcp/src/tools/session_context.rs | 67 ++- crates/vestige-mcp/src/tools/timeline.rs | 83 ++-- docs/CONFIGURATION.md | 87 ++++ package.json | 2 +- packages/vestige-init/package.json | 2 +- packages/vestige-mcp-npm/package.json | 2 +- server.json | 4 +- 18 files changed, 925 insertions(+), 120 deletions(-) create mode 100644 crates/vestige-core/src/config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 490c01e..f9e6074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.24] - 2026-06-11 — "Configurable Output" + +Roadmap **Phase 2: Configurable Output**. Users can now control the default +shape and size of high-traffic MCP responses with an optional, local-first +config file — without recompiling and without a cloud service. The default +behavior is unchanged: a fresh install with no `vestige.toml` behaves exactly +as before. + +### Added + +- **`vestige.toml` config file**, loaded from the active Vestige data directory + (`/vestige.toml`, alongside `vestige.db`). A missing or malformed + file falls back to built-in defaults, so existing installs are unaffected. +- **`[defaults]` table** with three keys: `detail_level` + (`brief` | `summary` | `full`), `limit` (default result count for + high-traffic tools), and `profile`. +- **Output profiles** — `lean`, `default`, `audit`, `research` — each presetting + a coherent bundle of detail level, result limit, and whether scores and + timestamps are included: + - `lean`: `brief` detail, limit 5, scores and timestamps dropped (smallest + context cost). + - `default`: historical behavior — `summary` detail, tool's own default + limit, scores and timestamps present. **Unchanged.** + - `audit`: `full` detail with every field, score, and timestamp. + - `research`: `full` detail with a larger default limit (25). +- **Three-layer precedence**, applied per call: an explicit MCP parameter wins + over the config file, which wins over the built-in default. +- **`profile` field** echoed in `search`, `memory_timeline`, `codebase` + (`get_context`), and `session_context` responses so the active profile is + observable. + +### Changed + +- `search`, `memory_timeline`, `codebase` (`get_context`), and + `session_context` now resolve their default detail level and result limit + through the config file when no explicit parameter is supplied. With no + `vestige.toml` present, their output is byte-for-byte identical to v2.1.23. + +### Documentation + +- `docs/CONFIGURATION.md` gains a **Output Configuration (`vestige.toml`)** + section documenting the file location, `[defaults]` keys, profile presets, + and precedence rules. + ## [2.1.23] - 2026-05-27 — "Receipt Lock Hardening" v2.1.23 hardens the Sanhedrin launch path so Receipt Lock is portable, diff --git a/Cargo.lock b/Cargo.lock index b9612d3..e059ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4629,7 +4629,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vestige-core" -version = "2.1.23" +version = "2.1.24" dependencies = [ "candle-core", "chrono", @@ -4665,7 +4665,7 @@ dependencies = [ [[package]] name = "vestige-mcp" -version = "2.1.23" +version = "2.1.24" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index f120928..544242c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ exclude = [ ] [workspace.package] -version = "2.1.23" +version = "2.1.24" edition = "2024" license = "AGPL-3.0-only" repository = "https://github.com/samvallad33/vestige" diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 4023f8d..ef23276 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@vestige/dashboard", - "version": "2.1.23", + "version": "2.1.24", "private": true, "type": "module", "scripts": { diff --git a/crates/vestige-core/Cargo.toml b/crates/vestige-core/Cargo.toml index ac1d5fa..74b6daa 100644 --- a/crates/vestige-core/Cargo.toml +++ b/crates/vestige-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vestige-core" -version = "2.1.23" +version = "2.1.24" edition = "2024" rust-version = "1.91" authors = ["Vestige Team"] diff --git a/crates/vestige-core/src/config.rs b/crates/vestige-core/src/config.rs new file mode 100644 index 0000000..f1bb8d5 --- /dev/null +++ b/crates/vestige-core/src/config.rs @@ -0,0 +1,388 @@ +//! Vestige configuration file (`vestige.toml`). +//! +//! Phase 2 "Configurable Output" of the adoption roadmap. A small, optional +//! config file lives alongside the SQLite database in the active Vestige data +//! directory (`/vestige.toml`). It lets users tune the default shape +//! of high-traffic MCP responses (detail level, result limit, output profile) +//! without recompiling and without a cloud service. +//! +//! Precedence, from highest to lowest: +//! +//! 1. An explicit MCP call parameter (e.g. `detail_level` on a `search` call). +//! 2. The config file `[defaults]` (and the selected output profile). +//! 3. The built-in default, which preserves the historical behavior so nothing +//! changes for users who never write a `vestige.toml`. +//! +//! The parser is intentionally a tiny, dependency-free subset of TOML: section +//! headers (`[defaults]`) and `key = value` lines with string or integer +//! values. This keeps the local-first binary lean and avoids pulling a full +//! TOML crate into the dependency tree for a three-key schema. Unknown keys and +//! unknown sections are ignored so the file can grow in future phases without +//! breaking older binaries. + +use std::path::{Path, PathBuf}; + +/// Canonical config file name, resolved inside the active data directory. +pub const CONFIG_FILE: &str = "vestige.toml"; + +/// Output profiles preset a coherent bundle of detail/field choices. +/// +/// `Default` MUST reproduce the pre-Phase-2 behavior exactly so existing users +/// see no change. The other profiles are opt-in presets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputProfile { + /// Smallest responses: brief detail, scores and timestamps suppressed. + /// Use when context budget matters more than provenance. + Lean, + /// Historical behavior. `summary` detail with content + dates. Unchanged. + #[default] + Default, + /// Maximum provenance: `full` detail with every field, score, and timestamp. + /// Use when reviewing or debugging memory state. + Audit, + /// Like `audit` but tuned for larger result sets (higher default limit). + Research, +} + +impl OutputProfile { + /// Parse a profile name. Returns `None` for unknown names so the caller can + /// decide whether that is an error (MCP param) or ignorable (config file). + pub fn from_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "lean" => Some(Self::Lean), + "default" => Some(Self::Default), + "audit" => Some(Self::Audit), + "research" => Some(Self::Research), + _ => None, + } + } + + /// Canonical lowercase name, suitable for echoing back in responses. + pub fn as_str(self) -> &'static str { + match self { + Self::Lean => "lean", + Self::Default => "default", + Self::Audit => "audit", + Self::Research => "research", + } + } + + /// The detail level this profile presets when the user has not set one + /// explicitly via an MCP param or `[defaults] detail_level`. + pub fn detail_level(self) -> &'static str { + match self { + Self::Lean => "brief", + Self::Default => "summary", + Self::Audit | Self::Research => "full", + } + } + + /// The result limit this profile presets when the user has not set one + /// explicitly. `None` means "use the tool's own historical default", which + /// keeps `default` fully backward-compatible. + pub fn limit(self) -> Option { + match self { + Self::Lean => Some(5), + Self::Default => None, + Self::Audit => None, + Self::Research => Some(25), + } + } + + /// Whether scores (combined/keyword/semantic) should be shown by default. + /// Lean drops them to save tokens; the rest keep whatever the detail level + /// already includes. + pub fn show_scores(self) -> bool { + !matches!(self, Self::Lean) + } + + /// Whether timestamps should be shown by default. Lean drops them. + pub fn show_timestamps(self) -> bool { + !matches!(self, Self::Lean) + } +} + +/// The `[defaults]` table from `vestige.toml`. All fields optional. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct OutputDefaults { + /// Default detail level (`brief` | `summary` | `full`). Overrides the + /// profile's preset detail level when set. + pub detail_level: Option, + /// Default result limit for high-traffic tools. Overrides the profile's + /// preset limit when set. + pub limit: Option, + /// Selected output profile. Defaults to `default` (historical behavior). + pub profile: OutputProfile, +} + +/// Parsed `vestige.toml`. Currently only the `[defaults]` table is meaningful; +/// the struct exists so future phases can add tables without churn. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct VestigeConfig { + pub defaults: OutputDefaults, +} + +impl VestigeConfig { + /// Resolve the config path for a given data directory. + pub fn path_for_data_dir(data_dir: &Path) -> PathBuf { + data_dir.join(CONFIG_FILE) + } + + /// Load config from a data directory. A missing or unreadable file yields + /// the built-in default (never an error) so a fresh install just works. + /// A present-but-malformed file is parsed leniently: only well-formed lines + /// are honored. + pub fn load_from_data_dir(data_dir: &Path) -> Self { + let path = Self::path_for_data_dir(data_dir); + match std::fs::read_to_string(&path) { + Ok(contents) => Self::parse(&contents), + Err(_) => Self::default(), + } + } + + /// Parse the minimal TOML subset. Lenient by design. + pub fn parse(contents: &str) -> Self { + let mut config = Self::default(); + let mut section = String::new(); + + for raw in contents.lines() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + + if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + section = name.trim().to_ascii_lowercase(); + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_lowercase(); + let value = unquote(value.trim()); + + if section == "defaults" { + match key.as_str() { + "detail_level" => { + let v = value.trim().to_ascii_lowercase(); + if matches!(v.as_str(), "brief" | "summary" | "full") { + config.defaults.detail_level = Some(v); + } + } + "limit" => { + if let Ok(n) = value.trim().parse::() + && n > 0 + { + config.defaults.limit = Some(n); + } + } + "profile" => { + if let Some(p) = OutputProfile::from_name(&value) { + config.defaults.profile = p; + } + } + _ => {} + } + } + } + + config + } + + /// Effective output config after applying the profile, with `[defaults]` + /// detail_level / limit overriding the profile presets. + pub fn output(&self) -> OutputConfig { + let profile = self.defaults.profile; + OutputConfig { + profile, + detail_level: self + .defaults + .detail_level + .clone() + .unwrap_or_else(|| profile.detail_level().to_string()), + limit: self.defaults.limit.or_else(|| profile.limit()), + show_scores: profile.show_scores(), + show_timestamps: profile.show_timestamps(), + } + } +} + +/// The resolved, ready-to-apply output configuration handed to MCP tools. +/// +/// Tools treat each field as the *fallback* used only when the corresponding +/// explicit MCP call parameter is absent, preserving the precedence +/// `MCP param > config file > built-in default`. +#[derive(Debug, Clone, PartialEq)] +pub struct OutputConfig { + pub profile: OutputProfile, + pub detail_level: String, + pub limit: Option, + pub show_scores: bool, + pub show_timestamps: bool, +} + +impl Default for OutputConfig { + /// The built-in default == the historical behavior == the `default` profile. + fn default() -> Self { + VestigeConfig::default().output() + } +} + +impl OutputConfig { + /// Resolve the detail level to use, given an optional explicit MCP param. + /// Explicit param always wins (precedence layer 1). + pub fn resolve_detail_level(&self, explicit: Option<&str>) -> String { + explicit + .map(|s| s.to_string()) + .unwrap_or_else(|| self.detail_level.clone()) + } + + /// Resolve the limit to use, given an optional explicit MCP param and the + /// tool's own built-in fallback (used only when neither param nor config + /// supplies one). + pub fn resolve_limit(&self, explicit: Option, builtin_default: i32) -> i32 { + explicit + .or(self.limit) + .unwrap_or(builtin_default) + } +} + +/// Strip a `#` comment that is not inside a quoted string. +fn strip_comment(line: &str) -> &str { + let mut in_quotes = false; + for (idx, ch) in line.char_indices() { + match ch { + '"' => in_quotes = !in_quotes, + '#' if !in_quotes => return &line[..idx], + _ => {} + } + } + line +} + +/// Remove a single layer of matching surrounding double quotes, if present. +fn unquote(value: &str) -> String { + let bytes = value.as_bytes(); + if bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"' { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_preserves_historical_behavior() { + let out = OutputConfig::default(); + assert_eq!(out.profile, OutputProfile::Default); + assert_eq!(out.detail_level, "summary"); + assert_eq!(out.limit, None); + assert!(out.show_scores); + assert!(out.show_timestamps); + } + + #[test] + fn empty_or_missing_file_is_default() { + assert_eq!(VestigeConfig::parse(""), VestigeConfig::default()); + assert_eq!(VestigeConfig::parse("\n\n# just a comment\n"), VestigeConfig::default()); + } + + #[test] + fn parses_defaults_table() { + let cfg = VestigeConfig::parse( + r#" + [defaults] + detail_level = "full" + limit = 25 + profile = "research" + "#, + ); + assert_eq!(cfg.defaults.detail_level.as_deref(), Some("full")); + assert_eq!(cfg.defaults.limit, Some(25)); + assert_eq!(cfg.defaults.profile, OutputProfile::Research); + } + + #[test] + fn unquoted_and_commented_values() { + let cfg = VestigeConfig::parse( + "[defaults]\nprofile = lean # inline comment\nlimit = 7\n", + ); + assert_eq!(cfg.defaults.profile, OutputProfile::Lean); + assert_eq!(cfg.defaults.limit, Some(7)); + } + + #[test] + fn invalid_values_are_ignored() { + let cfg = VestigeConfig::parse( + "[defaults]\ndetail_level = \"loud\"\nlimit = -3\nprofile = \"galaxy\"\n", + ); + // All invalid -> fall back to defaults. + assert_eq!(cfg.defaults.detail_level, None); + assert_eq!(cfg.defaults.limit, None); + assert_eq!(cfg.defaults.profile, OutputProfile::Default); + } + + #[test] + fn unknown_sections_and_keys_ignored() { + let cfg = VestigeConfig::parse( + "[future_phase]\nfoo = 1\n[defaults]\nprofile = audit\nbar = baz\n", + ); + assert_eq!(cfg.defaults.profile, OutputProfile::Audit); + } + + #[test] + fn profile_presets() { + // lean: brief + dropped scores/timestamps + small limit + let lean = VestigeConfig::parse("[defaults]\nprofile=lean").output(); + assert_eq!(lean.detail_level, "brief"); + assert_eq!(lean.limit, Some(5)); + assert!(!lean.show_scores); + assert!(!lean.show_timestamps); + + // audit: full detail, no forced limit + let audit = VestigeConfig::parse("[defaults]\nprofile=audit").output(); + assert_eq!(audit.detail_level, "full"); + assert_eq!(audit.limit, None); + + // research: full detail, larger limit + let research = VestigeConfig::parse("[defaults]\nprofile=research").output(); + assert_eq!(research.detail_level, "full"); + assert_eq!(research.limit, Some(25)); + } + + #[test] + fn explicit_defaults_override_profile_presets() { + // profile=lean would give brief/limit 5, but explicit keys win. + let out = VestigeConfig::parse( + "[defaults]\nprofile=lean\ndetail_level=\"full\"\nlimit=42\n", + ) + .output(); + assert_eq!(out.detail_level, "full"); + assert_eq!(out.limit, Some(42)); + } + + #[test] + fn precedence_mcp_param_wins() { + let out = VestigeConfig::parse("[defaults]\nprofile=lean").output(); + // Config says brief, but an explicit MCP param wins. + assert_eq!(out.resolve_detail_level(Some("full")), "full"); + // No explicit param -> config (lean -> brief). + assert_eq!(out.resolve_detail_level(None), "brief"); + } + + #[test] + fn precedence_limit_layers() { + let out = VestigeConfig::parse("[defaults]\nprofile=research").output(); + // explicit param wins over everything + assert_eq!(out.resolve_limit(Some(3), 10), 3); + // no param -> config (research -> 25) + assert_eq!(out.resolve_limit(None, 10), 25); + // default profile has no limit -> builtin fallback used + let def = OutputConfig::default(); + assert_eq!(def.resolve_limit(None, 10), 10); + } +} diff --git a/crates/vestige-core/src/lib.rs b/crates/vestige-core/src/lib.rs index 640ba4a..d1730a6 100644 --- a/crates/vestige-core/src/lib.rs +++ b/crates/vestige-core/src/lib.rs @@ -80,6 +80,8 @@ // MODULES // ============================================================================ +/// Optional `vestige.toml` configuration (Phase 2: Configurable Output). +pub mod config; pub mod consolidation; pub mod fsrs; pub mod fts; @@ -150,6 +152,9 @@ pub use fsrs::{ retrievability_with_decay, }; +// Configuration (vestige.toml output profiles / defaults) +pub use config::{OutputConfig, OutputDefaults, OutputProfile, VestigeConfig, CONFIG_FILE}; + // Storage layer pub use storage::{ ConnectionRecord, ConsolidationHistoryRecord, DreamHistoryRecord, InsightRecord, diff --git a/crates/vestige-mcp/Cargo.toml b/crates/vestige-mcp/Cargo.toml index 6485504..a0565a9 100644 --- a/crates/vestige-mcp/Cargo.toml +++ b/crates/vestige-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vestige-mcp" -version = "2.1.23" +version = "2.1.24" edition = "2024" description = "Cognitive memory MCP server for AI agents - FSRS-6, spreading activation, synaptic tagging, 3D dashboard, and 130 years of memory research" authors = ["samvallad33"] @@ -51,7 +51,7 @@ path = "src/bin/cli.rs" # Only `bundled-sqlite` is always on. `embeddings` and `vector-search` are # toggled via vestige-mcp's own feature flags below so `--no-default-features` # actually works (previously hardcoded here, which silently defeated the flag). -vestige-core = { version = "2.1.23", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] } +vestige-core = { version = "2.1.24", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] } # ============================================================================ # MCP Server Dependencies diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index a409ff5..76316fb 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -20,7 +20,7 @@ use crate::protocol::messages::{ use crate::protocol::types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, MCP_VERSION}; use crate::resources; use crate::tools; -use vestige_core::Storage; +use vestige_core::{OutputConfig, Storage, VestigeConfig}; /// Build the MCP `instructions` string injected into every connecting client's /// system prompt. @@ -77,17 +77,31 @@ pub struct McpServer { tool_call_count: AtomicU64, /// Optional event broadcast channel for dashboard real-time updates. event_tx: Option>, + /// Resolved output config from `/vestige.toml` (Phase 2). Tools + /// use it as the fallback for detail/limit when no explicit MCP param is + /// given; explicit params always win. + output_config: Arc, +} + +/// Load `vestige.toml` from the storage's data directory and resolve it to an +/// effective [`OutputConfig`]. A missing/malformed file yields the built-in +/// default, which preserves historical behavior. +fn load_output_config(storage: &Arc) -> Arc { + let config = VestigeConfig::load_from_data_dir(storage.data_dir()); + Arc::new(config.output()) } impl McpServer { #[allow(dead_code)] pub fn new(storage: Arc, cognitive: Arc>) -> Self { + let output_config = load_output_config(&storage); Self { storage, cognitive, initialized: false, tool_call_count: AtomicU64::new(0), event_tx: None, + output_config, } } @@ -97,12 +111,14 @@ impl McpServer { cognitive: Arc>, event_tx: broadcast::Sender, ) -> Self { + let output_config = load_output_config(&storage); Self { storage, cognitive, initialized: false, tool_call_count: AtomicU64::new(0), event_tx: Some(event_tx), + output_config, } } @@ -491,16 +507,26 @@ impl McpServer { // UNIFIED TOOLS (v1.1+) - Preferred API // ================================================================ "search" => { - tools::search_unified::execute(&self.storage, &self.cognitive, request.arguments) - .await + tools::search_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await } "memory" => { tools::memory_unified::execute(&self.storage, &self.cognitive, request.arguments) .await } "codebase" => { - tools::codebase_unified::execute(&self.storage, &self.cognitive, request.arguments) - .await + tools::codebase_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await } "intention" => { tools::intention_unified::execute(&self.storage, &self.cognitive, request.arguments) @@ -615,8 +641,13 @@ impl McpServer { "Tool '{}' is deprecated. Use 'search' instead.", request.name ); - tools::search_unified::execute(&self.storage, &self.cognitive, request.arguments) - .await + tools::search_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await } // ================================================================ @@ -696,7 +727,13 @@ impl McpServer { } None => Some(serde_json::json!({"action": "remember_pattern"})), }; - tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await + tools::codebase_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + unified_args, + ) + .await } "remember_decision" => { warn!( @@ -715,7 +752,13 @@ impl McpServer { } None => Some(serde_json::json!({"action": "remember_decision"})), }; - tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await + tools::codebase_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + unified_args, + ) + .await } "get_codebase_context" => { warn!( @@ -731,7 +774,13 @@ impl McpServer { } None => Some(serde_json::json!({"action": "get_context"})), }; - tools::codebase_unified::execute(&self.storage, &self.cognitive, unified_args).await + tools::codebase_unified::execute( + &self.storage, + &self.cognitive, + &self.output_config, + unified_args, + ) + .await } // ================================================================ @@ -863,7 +912,9 @@ impl McpServer { // ================================================================ // TEMPORAL TOOLS (v1.2+) // ================================================================ - "memory_timeline" => tools::timeline::execute(&self.storage, request.arguments).await, + "memory_timeline" => { + tools::timeline::execute(&self.storage, &self.output_config, request.arguments).await + } "memory_changelog" => tools::changelog::execute(&self.storage, request.arguments).await, // ================================================================ @@ -913,8 +964,13 @@ impl McpServer { // CONTEXT PACKETS (v1.8+) // ================================================================ "session_context" => { - tools::session_context::execute(&self.storage, &self.cognitive, request.arguments) - .await + tools::session_context::execute( + &self.storage, + &self.cognitive, + &self.output_config, + request.arguments, + ) + .await } // ================================================================ diff --git a/crates/vestige-mcp/src/tools/codebase_unified.rs b/crates/vestige-mcp/src/tools/codebase_unified.rs index 726ab4b..70d8007 100644 --- a/crates/vestige-mcp/src/tools/codebase_unified.rs +++ b/crates/vestige-mcp/src/tools/codebase_unified.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use tokio::sync::Mutex; use crate::cognitive::CognitiveEngine; -use vestige_core::{IngestInput, Storage}; +use vestige_core::{IngestInput, OutputConfig, Storage}; + +use super::search_unified::apply_output_masks; /// Input schema for the unified codebase tool pub fn schema() -> Value { @@ -87,6 +89,7 @@ struct CodebaseArgs { pub async fn execute( storage: &Arc, cognitive: &Arc>, + output_config: &OutputConfig, args: Option, ) -> Result { let args: CodebaseArgs = match args { @@ -97,7 +100,7 @@ pub async fn execute( match args.action.as_str() { "remember_pattern" => execute_remember_pattern(storage, cognitive, &args).await, "remember_decision" => execute_remember_decision(storage, cognitive, &args).await, - "get_context" => execute_get_context(storage, cognitive, &args).await, + "get_context" => execute_get_context(storage, cognitive, output_config, &args).await, _ => Err(format!( "Invalid action '{}'. Must be one of: remember_pattern, remember_decision, get_context", args.action @@ -282,9 +285,11 @@ async fn execute_remember_decision( async fn execute_get_context( storage: &Arc, cognitive: &Arc>, + output_config: &OutputConfig, args: &CodebaseArgs, ) -> Result { - let limit = args.limit.unwrap_or(10).clamp(1, 50); + // Precedence: explicit MCP param > config limit > built-in default (10). + let limit = output_config.resolve_limit(args.limit, 10).clamp(1, 50); // Build tag filter for codebase let tag_filter = args.codebase.as_ref().map(|cb| format!("codebase:{}", cb)); @@ -299,7 +304,7 @@ async fn execute_get_context( .get_nodes_by_type_and_tag("decision", tag_filter.as_deref(), limit) .unwrap_or_default(); - let formatted_patterns: Vec = patterns + let mut formatted_patterns: Vec = patterns .iter() .map(|n| { serde_json::json!({ @@ -311,8 +316,9 @@ async fn execute_get_context( }) }) .collect(); + apply_output_masks(&mut formatted_patterns, output_config); - let formatted_decisions: Vec = decisions + let mut formatted_decisions: Vec = decisions .iter() .map(|n| { serde_json::json!({ @@ -324,6 +330,7 @@ async fn execute_get_context( }) }) .collect(); + apply_output_masks(&mut formatted_decisions, output_config); // ==================================================================== // COGNITIVE: Cross-project knowledge discovery @@ -352,6 +359,7 @@ async fn execute_get_context( Ok(serde_json::json!({ "action": "get_context", "codebase": args.codebase, + "profile": output_config.profile.as_str(), "patterns": { "count": formatted_patterns.len(), "items": formatted_patterns, @@ -411,7 +419,7 @@ mod tests { #[tokio::test] async fn test_missing_args_fails() { let (storage, _dir) = test_storage().await; - let result = execute(&storage, &test_cognitive(), None).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), None).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing arguments")); } @@ -420,7 +428,7 @@ mod tests { async fn test_invalid_action_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "invalid" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid action")); } @@ -435,7 +443,7 @@ mod tests { "files": ["src/lib.rs"], "codebase": "vestige" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "remember_pattern"); @@ -451,7 +459,7 @@ mod tests { "action": "remember_pattern", "description": "Some description" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'name' is required")); } @@ -463,7 +471,7 @@ mod tests { "action": "remember_pattern", "name": "Test Pattern" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'description' is required")); } @@ -476,7 +484,7 @@ mod tests { "name": " ", "description": "Some description" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } @@ -492,7 +500,7 @@ mod tests { "files": ["src/storage.rs"], "codebase": "vestige" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "remember_decision"); @@ -507,7 +515,7 @@ mod tests { "action": "remember_decision", "rationale": "Some rationale" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'decision' is required")); } @@ -519,7 +527,7 @@ mod tests { "action": "remember_decision", "decision": "Use SQLite" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("'rationale' is required")); } @@ -532,7 +540,7 @@ mod tests { "decision": " ", "rationale": "Something" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } @@ -544,7 +552,7 @@ mod tests { "action": "get_context", "codebase": "nonexistent" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "get_context"); @@ -563,14 +571,14 @@ mod tests { "description": "A test pattern", "codebase": "myproject" }); - execute(&storage, &cog, Some(save_args)).await.unwrap(); + execute(&storage, &cog, &OutputConfig::default(), Some(save_args)).await.unwrap(); // Now retrieve let get_args = serde_json::json!({ "action": "get_context", "codebase": "myproject" }); - let result = execute(&storage, &cog, Some(get_args)).await; + let result = execute(&storage, &cog, &OutputConfig::default(), Some(get_args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert!(value["patterns"]["count"].as_u64().unwrap() >= 1); @@ -580,10 +588,35 @@ mod tests { async fn test_get_context_no_codebase() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "action": "get_context" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["action"], "get_context"); assert!(value["codebase"].is_null()); } + + /// Phase 2: the `lean` profile masks the `createdAt` timestamp from + /// get_context items, and the response echoes the active profile. + #[tokio::test] + async fn test_get_context_lean_profile_masks_timestamps() { + let (storage, _dir) = test_storage().await; + let cog = test_cognitive(); + let save_args = serde_json::json!({ + "action": "remember_pattern", + "name": "Lean Pattern", + "description": "A pattern for lean masking", + "codebase": "leanproj" + }); + execute(&storage, &cog, &OutputConfig::default(), Some(save_args)) + .await + .unwrap(); + + let cfg = vestige_core::VestigeConfig::parse("[defaults]\nprofile=lean").output(); + let get_args = serde_json::json!({ "action": "get_context", "codebase": "leanproj" }); + let value = execute(&storage, &cog, &cfg, Some(get_args)).await.unwrap(); + assert_eq!(value["profile"], "lean"); + let item = &value["patterns"]["items"][0]; + assert!(item.get("createdAt").is_none(), "lean must drop createdAt"); + assert!(item.get("content").is_some(), "content still present"); + } } diff --git a/crates/vestige-mcp/src/tools/search_unified.rs b/crates/vestige-mcp/src/tools/search_unified.rs index 9faf961..c33284e 100644 --- a/crates/vestige-mcp/src/tools/search_unified.rs +++ b/crates/vestige-mcp/src/tools/search_unified.rs @@ -21,8 +21,8 @@ use tokio::sync::Mutex; use crate::cognitive::CognitiveEngine; use vestige_core::{ - CompetitionCandidate, EncodingContext, MemoryLifecycle, MemorySnapshot, MemoryState, Storage, - TopicalContext, + CompetitionCandidate, EncodingContext, MemoryLifecycle, MemorySnapshot, MemoryState, + OutputConfig, Storage, TopicalContext, }; /// Input schema for unified search tool @@ -137,6 +137,7 @@ struct SearchArgs { pub async fn execute( storage: &Arc, cognitive: &Arc>, + output_config: &OutputConfig, args: Option, ) -> Result { let args: SearchArgs = match args { @@ -148,12 +149,16 @@ pub async fn execute( return Err("Query cannot be empty".to_string()); } - // Validate detail_level - let detail_level = match args.detail_level.as_deref() { - Some("brief") => "brief", - Some("full") => "full", - Some("summary") | None => "summary", - Some(invalid) => { + // Validate detail_level. Precedence: explicit MCP param > config file > + // built-in default. The explicit arg is validated; the config fallback is + // already validated at load time. + let detail_level_owned = + output_config.resolve_detail_level(args.detail_level.as_deref()); + let detail_level = match detail_level_owned.as_str() { + "brief" => "brief", + "full" => "full", + "summary" => "summary", + invalid => { return Err(format!( "Invalid detail_level '{}'. Must be 'brief', 'summary', or 'full'.", invalid @@ -161,8 +166,9 @@ pub async fn execute( } }; - // Clamp all parameters to valid ranges - let limit = args.limit.unwrap_or(10).clamp(1, 100); + // Clamp all parameters to valid ranges. The default limit honors the + // config file (e.g. a `research` profile) when no explicit param is set. + let limit = output_config.resolve_limit(args.limit, 10).clamp(1, 100); let min_retention = args.min_retention.unwrap_or(0.0).clamp(0.0, 1.0); let min_similarity = args.min_similarity.unwrap_or(0.5).clamp(0.0, 1.0); @@ -200,6 +206,7 @@ pub async fn execute( .filter(|r| r.node.retention_strength >= min_retention) .map(|r| format_search_result(r, detail_level)) .collect(); + apply_output_masks(&mut formatted, output_config); let mut budget_expandable: Vec = Vec::new(); let mut budget_tokens_used: Option = None; @@ -231,6 +238,7 @@ pub async fn execute( "retrievalMode": retrieval_mode, "concrete": true, "detailLevel": detail_level, + "profile": output_config.profile.as_str(), "total": formatted.len(), "results": formatted, }); @@ -665,6 +673,7 @@ pub async fn execute( .iter() .map(|r| format_search_result(r, detail_level)) .collect(); + apply_output_masks(&mut formatted, output_config); // ==================================================================== // Token budget enforcement (v1.8.0) @@ -705,6 +714,7 @@ pub async fn execute( "method": "hybrid+cognitive", "retrievalMode": retrieval_mode, "detailLevel": detail_level, + "profile": output_config.profile.as_str(), "total": formatted.len(), "results": formatted, }); @@ -782,6 +792,42 @@ fn is_literal_query(query: &str) -> bool { } /// Format a search result based on the requested detail level. +/// Score field keys dropped when an output profile suppresses scores. +const SCORE_FIELDS: &[&str] = &["combinedScore", "keywordScore", "semanticScore"]; +/// Timestamp field keys dropped when an output profile suppresses timestamps. +const TIMESTAMP_FIELDS: &[&str] = &[ + "createdAt", + "updatedAt", + "lastAccessed", + "nextReview", + "validFrom", + "validUntil", +]; + +/// Strip score/timestamp fields from already-formatted result objects according +/// to the active output profile (e.g. the `lean` profile drops both). Tools +/// call this after formatting so the field-mask behavior is centralized and the +/// per-detail-level formatters stay unchanged. +pub fn apply_output_masks(results: &mut [Value], output_config: &OutputConfig) { + if output_config.show_scores && output_config.show_timestamps { + return; + } + for result in results.iter_mut() { + if let Some(obj) = result.as_object_mut() { + if !output_config.show_scores { + for key in SCORE_FIELDS { + obj.remove(*key); + } + } + if !output_config.show_timestamps { + for key in TIMESTAMP_FIELDS { + obj.remove(*key); + } + } + } + } +} + fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> Value { match detail_level { "brief" => serde_json::json!({ @@ -922,7 +968,7 @@ mod tests { async fn test_search_empty_query_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "query": "" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } @@ -931,7 +977,7 @@ mod tests { async fn test_search_whitespace_only_query_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "query": " \t\n " }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("empty")); } @@ -939,7 +985,7 @@ mod tests { #[tokio::test] async fn test_search_missing_arguments_fails() { let (storage, _dir) = test_storage().await; - let result = execute(&storage, &test_cognitive(), None).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), None).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing arguments")); } @@ -948,7 +994,7 @@ mod tests { async fn test_search_missing_query_field_fails() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "limit": 10 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid arguments")); } @@ -986,7 +1032,7 @@ mod tests { "query": "OPENAI_API_KEY", "limit": 5 }); - let result = execute(&storage, &test_cognitive(), Some(args)) + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)) .await .unwrap(); @@ -1010,7 +1056,7 @@ mod tests { "query": uuid, "limit": 5 }); - let result = execute(&storage, &test_cognitive(), Some(args)) + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)) .await .unwrap(); @@ -1036,7 +1082,7 @@ mod tests { "query": "mlx_lm.server", "limit": 5 }); - let result = execute(&storage, &test_cognitive(), Some(args)) + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)) .await .unwrap(); @@ -1058,7 +1104,7 @@ mod tests { "query": "test", "limit": 0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); } @@ -1072,7 +1118,7 @@ mod tests { "query": "test", "limit": 1000 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); } @@ -1085,7 +1131,7 @@ mod tests { "query": "test", "limit": -5 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); } @@ -1102,7 +1148,7 @@ mod tests { "query": "test", "min_retention": -0.5 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); } @@ -1115,7 +1161,7 @@ mod tests { "query": "test", "min_retention": 1.5 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; // Should succeed but may return no results (retention > 1.0 clamped to 1.0) assert!(result.is_ok()); } @@ -1133,7 +1179,7 @@ mod tests { "query": "test", "min_similarity": -0.5 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); } @@ -1146,7 +1192,7 @@ mod tests { "query": "test", "min_similarity": 1.5 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; // Should succeed but may return no results assert!(result.is_ok()); } @@ -1161,7 +1207,7 @@ mod tests { ingest_test_content(&storage, "The Rust programming language is memory safe.").await; let args = serde_json::json!({ "query": "rust" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1181,7 +1227,7 @@ mod tests { "query": "python", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1203,7 +1249,7 @@ mod tests { "limit": 2, "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1217,7 +1263,7 @@ mod tests { // Don't ingest anything - database is empty let args = serde_json::json!({ "query": "anything" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1234,7 +1280,7 @@ mod tests { "query": "testing", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1267,7 +1313,7 @@ mod tests { "query": "item", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1353,7 +1399,7 @@ mod tests { "detail_level": "brief", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1382,7 +1428,7 @@ mod tests { "detail_level": "full", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1409,7 +1455,7 @@ mod tests { "query": "default", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1438,7 +1484,7 @@ mod tests { "query": "test", "detail_level": "invalid_level" }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid detail_level")); } @@ -1467,7 +1513,7 @@ mod tests { "token_budget": 200, "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1494,7 +1540,7 @@ mod tests { "token_budget": 150, "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1513,7 +1559,7 @@ mod tests { "query": "no budget", "min_similarity": 0.0 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -1531,4 +1577,78 @@ mod tests { assert_eq!(tb["minimum"], 100); assert_eq!(tb["maximum"], 100000); } + + // ======================================================================== + // Phase 2: Configurable Output — precedence tests + // ======================================================================== + + /// Config-file detail_level applies when no explicit MCP param is given. + #[tokio::test] + async fn test_config_detail_level_applies_without_param() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Config detail level fallback content.").await; + + // Config selects `full`; the call passes no detail_level. + let cfg = vestige_core::VestigeConfig::parse("[defaults]\ndetail_level=\"full\"").output(); + let args = serde_json::json!({ "query": "config detail", "min_similarity": 0.0 }); + let value = execute(&storage, &test_cognitive(), &cfg, Some(args)) + .await + .unwrap(); + assert_eq!(value["detailLevel"], "full"); + } + + /// Explicit MCP param beats the config file (precedence layer 1 > 2). + #[tokio::test] + async fn test_explicit_param_overrides_config() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Explicit overrides config content.").await; + + // Config says `full`, but the call explicitly requests `brief`. + let cfg = vestige_core::VestigeConfig::parse("[defaults]\ndetail_level=\"full\"").output(); + let args = serde_json::json!({ + "query": "explicit override", + "detail_level": "brief", + "min_similarity": 0.0 + }); + let value = execute(&storage, &test_cognitive(), &cfg, Some(args)) + .await + .unwrap(); + assert_eq!(value["detailLevel"], "brief"); + } + + /// The `lean` profile masks scores and timestamps from results. + #[tokio::test] + async fn test_lean_profile_masks_scores_and_timestamps() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Lean profile masking content.").await; + + let cfg = vestige_core::VestigeConfig::parse("[defaults]\nprofile=lean").output(); + let args = serde_json::json!({ "query": "lean masking", "min_similarity": 0.0 }); + let value = execute(&storage, &test_cognitive(), &cfg, Some(args)) + .await + .unwrap(); + assert_eq!(value["profile"], "lean"); + if let Some(first) = value["results"].as_array().and_then(|a| a.first()) { + assert!(first.get("combinedScore").is_none(), "lean must drop scores"); + assert!(first.get("createdAt").is_none(), "lean must drop timestamps"); + } + } + + /// The default profile is byte-for-byte the historical behavior: summary + /// detail with scores and timestamps present. + #[tokio::test] + async fn test_default_profile_preserves_behavior() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Default profile preserved content.").await; + + let args = serde_json::json!({ "query": "default preserved", "min_similarity": 0.0 }); + let value = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)) + .await + .unwrap(); + assert_eq!(value["detailLevel"], "summary"); + assert_eq!(value["profile"], "default"); + if let Some(first) = value["results"].as_array().and_then(|a| a.first()) { + assert!(first.get("createdAt").is_some(), "default keeps timestamps"); + } + } } diff --git a/crates/vestige-mcp/src/tools/session_context.rs b/crates/vestige-mcp/src/tools/session_context.rs index 0a32ce4..68e5c6b 100644 --- a/crates/vestige-mcp/src/tools/session_context.rs +++ b/crates/vestige-mcp/src/tools/session_context.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use serde_json::Value; use crate::cognitive::CognitiveEngine; -use vestige_core::Storage; +use vestige_core::{OutputConfig, Storage}; /// Input schema for session_context tool pub fn schema() -> Value { @@ -98,6 +98,7 @@ fn first_sentence(content: &str) -> String { pub async fn execute( storage: &Arc, cognitive: &Arc>, + output_config: &OutputConfig, args: Option, ) -> Result { let args: SessionContextArgs = match args { @@ -105,6 +106,14 @@ pub async fn execute( None => SessionContextArgs::default(), }; + // Per-query search width honors the active profile (e.g. `research` widens + // it, `lean` narrows it). No explicit MCP param exists here, so the config + // limit (or built-in default of 5) applies. Capped to keep the budgeted + // session response compact. + let per_query_limit = output_config.resolve_limit(None, 5).clamp(1, 25); + // The `lean` profile suppresses the inline memory date to save tokens. + let show_dates = output_config.show_timestamps; + let token_budget = args.token_budget.unwrap_or(1000).clamp(100, 100000) as usize; let budget_chars = token_budget * 4; let include_status = args.include_status.unwrap_or(true); @@ -126,7 +135,7 @@ pub async fn execute( for query in &queries { let results = storage - .hybrid_search(query, 5, 0.3, 0.7) + .hybrid_search(query, per_query_limit, 0.3, 0.7) .map_err(|e| e.to_string())?; for r in results { @@ -134,8 +143,12 @@ pub async fn execute( continue; } let summary = first_sentence(&r.node.content); - let date_str = r.node.updated_at.format("%b %d, %Y").to_string(); - let line = format!("- ({}) {}", date_str, summary); + let line = if show_dates { + let date_str = r.node.updated_at.format("%b %d, %Y").to_string(); + format!("- ({}) {}", date_str, summary) + } else { + format!("- {}", summary) + }; let line_len = line.len() + 1; // +1 for newline if char_count + line_len > budget_chars { @@ -384,6 +397,7 @@ pub async fn execute( Ok(serde_json::json!({ "context": context_text, + "profile": output_config.profile.as_str(), "tokensUsed": tokens_used, "tokenBudget": token_budget, "expandable": expandable_ids, @@ -529,7 +543,7 @@ mod tests { #[tokio::test] async fn test_default_no_args() { let (storage, _dir) = test_storage().await; - let result = execute(&storage, &test_cognitive(), None).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), None).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -554,7 +568,7 @@ mod tests { let args = serde_json::json!({ "queries": ["user preferences", "project context"] }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -582,7 +596,7 @@ mod tests { "queries": ["memory"], "token_budget": 200 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -618,7 +632,7 @@ mod tests { "queries": ["expandable test memory"], "token_budget": 150 }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -629,7 +643,7 @@ mod tests { #[tokio::test] async fn test_automation_triggers_booleans() { let (storage, _dir) = test_storage().await; - let result = execute(&storage, &test_cognitive(), None).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), None).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -649,7 +663,7 @@ mod tests { "include_intentions": false, "include_predictions": false }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -683,7 +697,7 @@ mod tests { "topics": ["performance"] } }); - let result = execute(&storage, &test_cognitive(), Some(args)).await; + let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -692,6 +706,37 @@ mod tests { assert!(ctx.contains("vestige")); } + /// Phase 2: the response echoes the active profile, and the `lean` profile + /// suppresses inline memory dates to save tokens. + #[tokio::test] + async fn test_session_context_profile_echo_and_lean_dates() { + let (storage, _dir) = test_storage().await; + ingest_test_content(&storage, "Session profile content sentence.", vec![]).await; + + // Default profile -> profile echoed, dates present. + let args = serde_json::json!({ "queries": ["profile content"] }); + let value = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)) + .await + .unwrap(); + assert_eq!(value["profile"], "default"); + + // Lean profile -> profile echoed as lean. The memory line must not carry + // the "(Mon DD, YYYY)" inline date prefix. + let cfg = vestige_core::VestigeConfig::parse("[defaults]\nprofile=lean").output(); + let args = serde_json::json!({ "queries": ["profile content"] }); + let value = execute(&storage, &test_cognitive(), &cfg, Some(args)) + .await + .unwrap(); + assert_eq!(value["profile"], "lean"); + let ctx = value["context"].as_str().unwrap(); + if ctx.contains("**Memories:**") { + assert!( + !ctx.contains(", 20"), + "lean profile should omit the inline year in memory dates" + ); + } + } + // ======================================================================== // HELPER TESTS // ======================================================================== diff --git a/crates/vestige-mcp/src/tools/timeline.rs b/crates/vestige-mcp/src/tools/timeline.rs index 14e58bf..5c73357 100644 --- a/crates/vestige-mcp/src/tools/timeline.rs +++ b/crates/vestige-mcp/src/tools/timeline.rs @@ -9,9 +9,9 @@ use serde_json::Value; use std::collections::BTreeMap; use std::sync::Arc; -use vestige_core::Storage; +use vestige_core::{OutputConfig, Storage}; -use super::search_unified::format_node; +use super::search_unified::{apply_output_masks, format_node}; /// Input schema for memory_timeline tool pub fn schema() -> Value { @@ -87,7 +87,11 @@ fn parse_datetime(s: &str) -> Result, String> { } /// Execute memory_timeline tool -pub async fn execute(storage: &Arc, args: Option) -> Result { +pub async fn execute( + storage: &Arc, + output_config: &OutputConfig, + args: Option, +) -> Result { let args: TimelineArgs = match args { Some(v) => serde_json::from_value(v).map_err(|e| format!("Invalid arguments: {}", e))?, None => TimelineArgs { @@ -100,12 +104,13 @@ pub async fn execute(storage: &Arc, args: Option) -> Result "brief", - Some("full") => "full", - Some("summary") | None => "summary", - Some(invalid) => { + // Validate detail_level. Precedence: explicit MCP param > config > default. + let detail_level_owned = output_config.resolve_detail_level(args.detail_level.as_deref()); + let detail_level = match detail_level_owned.as_str() { + "brief" => "brief", + "full" => "full", + "summary" => "summary", + invalid => { return Err(format!( "Invalid detail_level '{}'. Must be 'brief', 'summary', or 'full'.", invalid @@ -124,7 +129,8 @@ pub async fn execute(storage: &Arc, args: Option) -> Result Some(now), }; - let limit = args.limit.unwrap_or(50).clamp(1, 200); + // Precedence: explicit MCP param > config limit > built-in default (50). + let limit = output_config.resolve_limit(args.limit, 50).clamp(1, 200); // Query memories in time range with filters pushed into SQL. Rust-side // `retain` after `LIMIT` was unsafe for sparse types/tags — a dominant @@ -140,14 +146,15 @@ pub async fn execute(storage: &Arc, args: Option) -> Result> = BTreeMap::new(); for node in &results { let date = node.created_at.date_naive(); - by_day - .entry(date) - .or_default() - .push(format_node(node, detail_level)); + let mut formatted = [format_node(node, detail_level)]; + apply_output_masks(&mut formatted, output_config); + let [formatted] = formatted; + by_day.entry(date).or_default().push(formatted); } // Build timeline (newest first) @@ -173,6 +180,7 @@ pub async fn execute(storage: &Arc, args: Option) -> Result= 1); @@ -296,7 +304,7 @@ mod tests { async fn test_timeline_invalid_detail_level() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "detail_level": "invalid" }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid detail_level")); } @@ -306,7 +314,7 @@ mod tests { let (storage, _dir) = test_storage().await; ingest_test_memory(&storage, "Brief test memory").await; let args = serde_json::json!({ "detail_level": "brief" }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["detailLevel"], "brief"); @@ -317,7 +325,7 @@ mod tests { let (storage, _dir) = test_storage().await; ingest_test_memory(&storage, "Full test memory").await; let args = serde_json::json!({ "detail_level": "full" }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["detailLevel"], "full"); @@ -327,7 +335,7 @@ mod tests { async fn test_timeline_limit_clamped() { let (storage, _dir) = test_storage().await; let args = serde_json::json!({ "limit": 0 }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); // limit clamped to 1, no error } @@ -339,7 +347,7 @@ mod tests { "start": "2020-01-01", "end": "2030-12-31" }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; assert!(result.is_ok()); let value = result.unwrap(); assert!(value["totalMemories"].as_u64().unwrap() >= 1); @@ -350,7 +358,7 @@ mod tests { let (storage, _dir) = test_storage().await; ingest_test_memory(&storage, "A fact memory").await; let args = serde_json::json!({ "node_type": "concept" }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; let value = result.unwrap(); // Ingested as "fact", filtering for "concept" should yield 0 assert_eq!(value["totalMemories"], 0); @@ -361,7 +369,7 @@ mod tests { let (storage, _dir) = test_storage().await; ingest_test_memory(&storage, "Tagged memory").await; let args = serde_json::json!({ "tags": ["timeline-test"] }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; let value = result.unwrap(); assert!(value["totalMemories"].as_u64().unwrap() >= 1); } @@ -371,7 +379,7 @@ mod tests { let (storage, _dir) = test_storage().await; ingest_test_memory(&storage, "Tagged memory").await; let args = serde_json::json!({ "tags": ["nonexistent-tag"] }); - let result = execute(&storage, Some(args)).await; + let result = execute(&storage, &OutputConfig::default(), Some(args)).await; let value = result.unwrap(); assert_eq!(value["totalMemories"], 0); } @@ -409,7 +417,7 @@ mod tests { // Limit 5 against 12 total — before the fix, `retain` on `concept` // would operate on the 5 most recent rows (all `fact`) and find 0. let args = serde_json::json!({ "node_type": "concept", "limit": 5 }); - let value = execute(&storage, Some(args)).await.unwrap(); + let value = execute(&storage, &OutputConfig::default(), Some(args)).await.unwrap(); assert_eq!( value["totalMemories"], 2, "Both sparse concepts should survive a limit smaller than the dominant set" @@ -447,7 +455,7 @@ mod tests { } let args = serde_json::json!({ "tags": ["rare"], "limit": 5 }); - let value = execute(&storage, Some(args)).await.unwrap(); + let value = execute(&storage, &OutputConfig::default(), Some(args)).await.unwrap(); assert_eq!( value["totalMemories"], 2, "Both sparse-tag matches should survive a limit smaller than the dominant set" @@ -479,4 +487,23 @@ mod tests { assert_eq!(nodes.len(), 1, "Only the exact-tag match should return"); assert_eq!(nodes[0].content, "Exact tag hit"); } + + /// Phase 2: config-file detail_level applies when no explicit param is set, + /// and an explicit param overrides it. + #[tokio::test] + async fn test_timeline_config_detail_precedence() { + let (storage, _dir) = test_storage().await; + ingest_test_memory(&storage, "Timeline config precedence content.").await; + + let cfg = vestige_core::VestigeConfig::parse("[defaults]\ndetail_level=\"full\"").output(); + + // No explicit param -> config wins. + let value = execute(&storage, &cfg, None).await.unwrap(); + assert_eq!(value["detailLevel"], "full"); + + // Explicit param -> overrides config. + let args = serde_json::json!({ "detail_level": "brief" }); + let value = execute(&storage, &cfg, Some(args)).await.unwrap(); + assert_eq!(value["detailLevel"], "brief"); + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2092663..1783ed1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -50,6 +50,93 @@ Qwen3 currently uses Hugging Face Hub's Candle loader directly, so use the stand --- +## Output Configuration (`vestige.toml`) + +> Added in **v2.1.24** (Roadmap Phase 2: Configurable Output). + +You can control the default shape and size of high-traffic MCP responses with an +optional config file. It is **local-first** — no cloud service is involved — and +**fully backward-compatible**: with no file present, Vestige behaves exactly as +it did before. + +### Location + +The config file lives in the active Vestige data directory, alongside the +database: + +``` +/vestige.toml # e.g. ~/Library/Application Support/com.vestige.core/vestige.toml +``` + +The data directory is resolved with the same precedence as storage +(`--data-dir` > `VESTIGE_DATA_DIR` > OS per-user data dir). A missing file, or a +file with no recognized keys, falls back to built-in defaults. The parser is +lenient: unknown keys and unknown sections are ignored, so the file can grow in +future releases without breaking older binaries. + +### `[defaults]` table + +```toml +[defaults] +# Detail level for high-traffic tools: "brief" | "summary" | "full" +detail_level = "summary" + +# Default result count for high-traffic tools (positive integer) +limit = 10 + +# Output profile: "lean" | "default" | "audit" | "research" +profile = "default" +``` + +All three keys are optional. `detail_level` and `limit`, when set, override the +selected profile's presets. + +### Output profiles + +A profile presets a coherent bundle of detail level, default limit, and whether +scores and timestamps are included: + +| Profile | Detail | Default limit | Scores | Timestamps | Use when | +|---------|--------|---------------|--------|------------|----------| +| `lean` | `brief` | 5 | dropped | dropped | Context budget matters most | +| `default` | `summary` | tool default | shown | shown | **Historical behavior (unchanged)** | +| `audit` | `full` | tool default | shown | shown | Reviewing or debugging memory state | +| `research` | `full` | 25 | shown | shown | Wide, detailed result sets | + +### Precedence + +Resolved per call, highest to lowest: + +1. **Explicit MCP parameter** (e.g. `detail_level` / `limit` on a `search` + call) — always wins. +2. **`vestige.toml`** — the `[defaults]` keys and the selected profile. +3. **Built-in default** — the `default` profile, identical to pre-v2.1.24 + behavior. + +### Affected tools + +`search`, `memory_timeline`, `codebase` (`get_context`), and `session_context` +resolve their default detail level and result limit through this config. Each of +these tools also echoes the active `profile` in its response so you can confirm +what was applied. Tools that take no `detail_level`/`limit` are unaffected. + +### Example: minimize context cost + +```toml +[defaults] +profile = "lean" +``` + +### Example: detailed audits without changing the profile + +```toml +[defaults] +detail_level = "full" +limit = 50 +``` + +--- + ## Command-Line Options ```bash diff --git a/package.json b/package.json index 9d759a6..b82ec0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vestige", - "version": "2.1.23", + "version": "2.1.24", "private": true, "description": "Cognitive memory for AI - MCP server with FSRS-6 spaced repetition", "author": "Sam Valladares", diff --git a/packages/vestige-init/package.json b/packages/vestige-init/package.json index 1a70a91..8729c39 100644 --- a/packages/vestige-init/package.json +++ b/packages/vestige-init/package.json @@ -1,6 +1,6 @@ { "name": "@vestige/init", - "version": "2.1.23", + "version": "2.1.24", "description": "Configure Vestige local memory for MCP-compatible AI agents", "bin": { "vestige-init": "bin/init.js" diff --git a/packages/vestige-mcp-npm/package.json b/packages/vestige-mcp-npm/package.json index 119093a..1c4178c 100644 --- a/packages/vestige-mcp-npm/package.json +++ b/packages/vestige-mcp-npm/package.json @@ -1,6 +1,6 @@ { "name": "vestige-mcp-server", - "version": "2.1.23", + "version": "2.1.24", "mcpName": "io.github.samvallad33/vestige", "description": "Vestige MCP Server — local cognitive memory for MCP-compatible AI agents", "bin": { diff --git a/server.json b/server.json index e11c5a4..b9cd756 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/samvallad33/vestige", "source": "github" }, - "version": "2.1.23", + "version": "2.1.24", "packages": [ { "registryType": "npm", "identifier": "vestige-mcp-server", - "version": "2.1.23", + "version": "2.1.24", "transport": { "type": "stdio" }