feat(config): Phase 2 Configurable Output — vestige.toml + output profiles (v2.1.26)

Rebased on v2.1.25 merge/supersede and bumped the post-release metadata to v2.1.26 so this branch does not roll versions backward.

Adds local vestige.toml defaults, output profiles, and MCP response precedence for search, timeline, codebase context, and session context.

Verified:
- cargo metadata --format-version 1 --locked --no-deps
- cargo test -p vestige-core config --no-fail-fast
- cargo test -p vestige-mcp config --no-fail-fast
This commit is contained in:
Sam Valladares 2026-06-15 13:49:15 -05:00
parent 51f08264f7
commit 47de61f2d2
18 changed files with 931 additions and 126 deletions

View file

@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.26] - 2026-06-15 — "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
(`<data_dir>/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.25.
### Documentation
- `docs/CONFIGURATION.md` gains a **Output Configuration (`vestige.toml`)**
section documenting the file location, `[defaults]` keys, profile presets,
and precedence rules.
## [2.1.25] - 2026-06-12 — "Merge / Supersede Controls"
v2.1.25 ships Phase 3: diff-previewed, confidence-gated, reversible,

4
Cargo.lock generated
View file

@ -4629,7 +4629,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vestige-core"
version = "2.1.25"
version = "2.1.26"
dependencies = [
"candle-core",
"chrono",
@ -4665,7 +4665,7 @@ dependencies = [
[[package]]
name = "vestige-mcp"
version = "2.1.25"
version = "2.1.26"
dependencies = [
"anyhow",
"axum",

View file

@ -10,7 +10,7 @@ exclude = [
]
[workspace.package]
version = "2.1.25"
version = "2.1.26"
edition = "2024"
license = "AGPL-3.0-only"
repository = "https://github.com/samvallad33/vestige"

View file

@ -1,6 +1,6 @@
{
"name": "@vestige/dashboard",
"version": "2.1.25",
"version": "2.1.26",
"private": true,
"type": "module",
"scripts": {

View file

@ -1,6 +1,6 @@
[package]
name = "vestige-core"
version = "2.1.25"
version = "2.1.26"
edition = "2024"
rust-version = "1.91"
authors = ["Vestige Team"]

View file

@ -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 (`<data_dir>/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<Self> {
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<i32> {
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<String>,
/// Default result limit for high-traffic tools. Overrides the profile's
/// preset limit when set.
pub limit: Option<i32>,
/// 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::<i32>()
&& 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<i32>,
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<i32>, 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);
}
}

View file

@ -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;
@ -152,6 +154,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,

View file

@ -1,6 +1,6 @@
[package]
name = "vestige-mcp"
version = "2.1.25"
version = "2.1.26"
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.25", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
vestige-core = { version = "2.1.26", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
# ============================================================================
# MCP Server Dependencies

View file

@ -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<broadcast::Sender<VestigeEvent>>,
/// Resolved output config from `<data_dir>/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<OutputConfig>,
}
/// 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<Storage>) -> Arc<OutputConfig> {
let config = VestigeConfig::load_from_data_dir(storage.data_dir());
Arc::new(config.output())
}
impl McpServer {
#[allow(dead_code)]
pub fn new(storage: Arc<Storage>, cognitive: Arc<Mutex<CognitiveEngine>>) -> 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<Mutex<CognitiveEngine>>,
event_tx: broadcast::Sender<VestigeEvent>,
) -> 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,
}
}
@ -537,16 +553,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)
@ -661,8 +687,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
}
// ================================================================
@ -742,7 +773,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!(
@ -761,7 +798,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!(
@ -777,7 +820,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
}
// ================================================================
@ -909,7 +958,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,
// ================================================================
@ -967,8 +1018,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
}
// ================================================================

View file

@ -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<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
output_config: &OutputConfig,
args: Option<Value>,
) -> Result<Value, String> {
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<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
output_config: &OutputConfig,
args: &CodebaseArgs,
) -> Result<Value, String> {
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<Value> = patterns
let mut formatted_patterns: Vec<Value> = 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<Value> = decisions
let mut formatted_decisions: Vec<Value> = 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");
}
}

View file

@ -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
@ -143,6 +143,7 @@ struct SearchArgs {
pub async fn execute(
storage: &Arc<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
output_config: &OutputConfig,
args: Option<Value>,
) -> Result<Value, String> {
let args: SearchArgs = match args {
@ -154,12 +155,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
@ -167,8 +172,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);
@ -230,6 +236,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<String> = Vec::new();
let mut budget_tokens_used: Option<usize> = None;
@ -261,6 +268,7 @@ pub async fn execute(
"retrievalMode": retrieval_mode,
"concrete": true,
"detailLevel": detail_level,
"profile": output_config.profile.as_str(),
"total": formatted.len(),
"results": formatted,
});
@ -715,6 +723,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)
@ -755,6 +764,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,
});
@ -844,6 +854,42 @@ fn tags_match_prefix(tags: &[String], prefix: &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!({
@ -984,7 +1030,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"));
}
@ -993,7 +1039,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"));
}
@ -1001,7 +1047,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"));
}
@ -1010,7 +1056,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"));
}
@ -1048,7 +1094,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();
@ -1072,7 +1118,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();
@ -1098,7 +1144,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();
@ -1120,7 +1166,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());
}
@ -1134,7 +1180,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());
}
@ -1147,7 +1193,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());
}
@ -1164,7 +1210,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());
}
@ -1177,7 +1223,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());
}
@ -1195,7 +1241,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());
}
@ -1208,7 +1254,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());
}
@ -1223,7 +1269,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();
@ -1243,7 +1289,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();
@ -1265,7 +1311,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();
@ -1279,7 +1325,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();
@ -1296,7 +1342,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();
@ -1329,7 +1375,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();
@ -1415,7 +1461,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();
@ -1444,7 +1490,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();
@ -1471,7 +1517,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();
@ -1500,7 +1546,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"));
}
@ -1529,7 +1575,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();
@ -1556,7 +1602,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();
@ -1575,7 +1621,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();
@ -1679,7 +1725,7 @@ mod tests {
"tag_prefix": "meeting:",
"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(), "{:?}", result);
let value = result.unwrap();
let results = value["results"].as_array().unwrap();
@ -1695,7 +1741,7 @@ mod tests {
// depends on the cognitive pipeline's competition/suppression
// dynamics, so assert a lower bound.
assert!(
results.len() >= 1,
!results.is_empty(),
"tag_prefix should leave at least one meeting:* result, got {}",
results.len()
);
@ -1722,7 +1768,7 @@ mod tests {
"tag_prefix": "project:",
"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();
let results = value["results"].as_array().unwrap();
@ -1753,13 +1799,13 @@ mod tests {
"query": "audit",
"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();
let results = value["results"].as_array().unwrap();
// Both should be retrievable since no tag_prefix is set.
assert!(
results.len() >= 1,
!results.is_empty(),
"expected at least one result with no tag_prefix"
);
}
@ -1786,7 +1832,7 @@ mod tests {
"concrete": true,
"tag_prefix": "meeting:"
});
let result = execute(&storage, &test_cognitive(), Some(args)).await;
let result = execute(&storage, &test_cognitive(), &OutputConfig::default(), Some(args)).await;
assert!(result.is_ok(), "{:?}", result);
let value = result.unwrap();
assert_eq!(value["method"], "concrete");
@ -1799,4 +1845,78 @@ mod tests {
assert!(has_meeting, "concrete result lacks meeting:* tag: {}", r);
}
}
// ========================================================================
// 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");
}
}
}

View file

@ -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<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
output_config: &OutputConfig,
args: Option<Value>,
) -> Result<Value, String> {
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
// ========================================================================

View file

@ -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<DateTime<Utc>, String> {
}
/// Execute memory_timeline tool
pub async fn execute(storage: &Arc<Storage>, args: Option<Value>) -> Result<Value, String> {
pub async fn execute(
storage: &Arc<Storage>,
output_config: &OutputConfig,
args: Option<Value>,
) -> Result<Value, String> {
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<Storage>, args: Option<Value>) -> Result<Valu
},
};
// 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 > 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<Storage>, args: Option<Value>) -> Result<Valu
None => 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<Storage>, args: Option<Value>) -> Result<Valu
)
.map_err(|e| e.to_string())?;
// Group by day
// Group by day, applying the active profile's field masks (e.g. `lean`
// drops timestamps) to each formatted node.
let mut by_day: BTreeMap<NaiveDate, Vec<Value>> = 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<Storage>, args: Option<Value>) -> Result<Valu
"end": end.map(|dt| dt.to_rfc3339()),
},
"detailLevel": detail_level,
"profile": output_config.profile.as_str(),
"totalMemories": total,
"days": days,
"timeline": timeline,
@ -262,7 +270,7 @@ mod tests {
#[tokio::test]
async fn test_timeline_no_args_defaults() {
let (storage, _dir) = test_storage().await;
let result = execute(&storage, None).await;
let result = execute(&storage, &OutputConfig::default(), None).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["tool"], "memory_timeline");
@ -274,7 +282,7 @@ mod tests {
#[tokio::test]
async fn test_timeline_empty_database() {
let (storage, _dir) = test_storage().await;
let result = execute(&storage, None).await;
let result = execute(&storage, &OutputConfig::default(), None).await;
let value = result.unwrap();
assert_eq!(value["totalMemories"], 0);
assert_eq!(value["days"], 0);
@ -286,7 +294,7 @@ mod tests {
let (storage, _dir) = test_storage().await;
ingest_test_memory(&storage, "Timeline test memory 1").await;
ingest_test_memory(&storage, "Timeline test memory 2").await;
let result = execute(&storage, None).await;
let result = execute(&storage, &OutputConfig::default(), None).await;
let value = result.unwrap();
assert_eq!(value["totalMemories"], 2);
assert!(value["days"].as_u64().unwrap() >= 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");
}
}

View file

@ -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.26** (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:
```
<data_dir>/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.26
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

View file

@ -1,6 +1,6 @@
{
"name": "vestige",
"version": "2.1.25",
"version": "2.1.26",
"private": true,
"description": "Cognitive memory for AI - MCP server with FSRS-6 spaced repetition",
"author": "Sam Valladares",

View file

@ -1,6 +1,6 @@
{
"name": "@vestige/init",
"version": "2.1.25",
"version": "2.1.26",
"description": "Configure Vestige local memory for MCP-compatible AI agents",
"bin": {
"vestige-init": "bin/init.js"

View file

@ -1,6 +1,6 @@
{
"name": "vestige-mcp-server",
"version": "2.1.25",
"version": "2.1.26",
"mcpName": "io.github.samvallad33/vestige",
"description": "Vestige MCP Server — local cognitive memory for MCP-compatible AI agents",
"bin": {

View file

@ -7,12 +7,12 @@
"url": "https://github.com/samvallad33/vestige",
"source": "github"
},
"version": "2.1.25",
"version": "2.1.26",
"packages": [
{
"registryType": "npm",
"identifier": "vestige-mcp-server",
"version": "2.1.25",
"version": "2.1.26",
"transport": {
"type": "stdio"
}