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

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) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-11 16:04:41 -05:00
parent 274c6c265f
commit 0474c6aafa
18 changed files with 925 additions and 120 deletions

View file

@ -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
(`<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.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,

4
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

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

View file

@ -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"]

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;
@ -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,

View file

@ -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

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,
}
}
@ -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
}
// ================================================================

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
@ -137,6 +137,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 {
@ -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<String> = Vec::new();
let mut budget_tokens_used: Option<usize> = 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");
}
}
}

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.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:
```
<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.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

View file

@ -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",

View file

@ -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"

View file

@ -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": {

View file

@ -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"
}