mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-12 20:45:16 +02:00
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:
parent
274c6c265f
commit
0474c6aafa
18 changed files with 925 additions and 120 deletions
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.24] - 2026-06-11 — "Configurable Output"
|
||||
|
||||
Roadmap **Phase 2: Configurable Output**. Users can now control the default
|
||||
shape and size of high-traffic MCP responses with an optional, local-first
|
||||
config file — without recompiling and without a cloud service. The default
|
||||
behavior is unchanged: a fresh install with no `vestige.toml` behaves exactly
|
||||
as before.
|
||||
|
||||
### Added
|
||||
|
||||
- **`vestige.toml` config file**, loaded from the active Vestige data directory
|
||||
(`<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
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@vestige/dashboard",
|
||||
"version": "2.1.23",
|
||||
"version": "2.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
388
crates/vestige-core/src/config.rs
Normal file
388
crates/vestige-core/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ========================================================================
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue