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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- **Budgeted context pack: `recall --pack` (#1281 Phase 1)** - `uteke recall --pack [--budget <chars>] [--exclude-ids <ids>]` returns a `{selected, skipped, budget_used, budget_chars}` envelope instead of a bare ranked list: rank-order preserving greedy fill of a character budget, with per-item skip reasons (`excluded` for caller-supplied memory IDs already injected this turn, `budget` for items that no longer fit). Deterministic and LLM-free — ranking is untouched (fusion RRF remains the default strategy); this is a selection primitive, not a re-ranker. Surfaces: CLI flags, HTTP `POST /recall` (`"pack": true`, `"budget_chars"`, `"exclude_ids"`), MCP `uteke_recall` (`pack`, `budget_chars`, `exclude_ids`). Phase 2 (MMR diversity) is an experiment gated on LongMemEval + redundancy benchmarks per the issue.

## [0.18.2] - 2026-09-18

Patch release. Theme: **fix the unified doc recall flooding bug (#1270)** —
Expand Down
12 changes: 12 additions & 0 deletions crates/uteke-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ pub enum Commands {
/// on document results (#689).
#[arg(long)]
enrich: bool,
/// Return a budgeted context pack instead of a bare ranked list
/// (#1281): rank-order preserving greedy fill of a character budget,
/// deterministic and LLM-free. Pair with --budget and --exclude-ids.
#[arg(long)]
pack: bool,
/// Character budget for --pack (default: 4000).
#[arg(long, default_value = "4000")]
budget: usize,
/// Memory IDs to exclude from a --pack result (already injected this
/// turn), comma-separated (#1281).
#[arg(long, value_delimiter = ',')]
exclude_ids: Vec<String>,
},
/// Show project context summary (memory counts, top tags, recent activity)
Context {
Expand Down
6 changes: 6 additions & 0 deletions crates/uteke-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu
r#type,
enrich,
explain,
pack,
budget,
exclude_ids,
} => recall::run_recall(
cli,
uteke,
Expand All @@ -117,6 +120,9 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu
r#type.as_deref(),
*enrich,
*explain,
*pack,
*budget,
exclude_ids,
),

Commands::Context { namespace } => {
Expand Down
41 changes: 41 additions & 0 deletions crates/uteke-cli/src/commands/recall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub(crate) fn run_recall(
search_type: Option<&str>,
enrich: bool,
explain: bool,
pack: bool,
budget: usize,
exclude_ids: &[String],
) -> Result<(), String> {
// Resolve search type: --type flag > default (All = unified)
let resolved_search_type = match search_type {
Expand Down Expand Up @@ -202,6 +205,44 @@ pub(crate) fn run_recall(
return Ok(());
}

// Budgeted context pack (#1281 Phase 1): unified recall + greedy
// character-budget fill. Deterministic, LLM-free, rank-order preserving.
// Entity/category filters are honoured by the core post-filter; memory
// filters that have no packed-path equivalent are rejected loudly
// instead of being silently dropped.
if pack {
if at.is_some() || related || where_filter.is_some() {
return Err(
"--pack does not support --at/--related/--where; rerun without them".to_string(),
);
}
let pack_result = uteke
.recall_unified_packed(
query,
limit,
tags_filter,
ns,
min_score,
resolved_search_type,
entity,
category,
enrich,
resolved_strategy,
budget,
exclude_ids,
)
.map_err(|e| format!("Failed to recall: {e}"))?;

uteke.reset_salience_recency_config();

if cli.json {
output::print_json(&pack_result);
} else {
output::print_pack_human(&pack_result);
}
return Ok(());
}

if use_unified {
// Unified search path (#531)
let unified_results = uteke
Expand Down
30 changes: 30 additions & 0 deletions crates/uteke-cli/src/output.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
//! Human-readable and JSON output helpers.

use uteke_core::pack_mode::ContextPack;

/// Print a value as JSON to stdout.
pub(crate) fn print_json<T: serde::Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}

/// Print a context pack (#1281) in human-readable form.
pub(crate) fn print_pack_human(pack: &ContextPack) {
println!(
"Context pack: {} selected, {} skipped, budget {}/{} chars",
pack.selected.len(),
pack.skipped.len(),
pack.budget_used,
pack.budget_chars
);
if pack.selected.is_empty() {
println!("No results fit the budget.");
return;
}
println!("\n── selected ──");
print_unified_human(&pack.selected);
if !pack.skipped.is_empty() {
println!("\n── skipped ──");
for s in &pack.skipped {
let id = s
.memory_id
.as_deref()
.map(|i| format!(" ({i})"))
.unwrap_or_default();
println!("• [{}] {}{id}", s.reason, s.content);
}
}
}

/// Print tags in human-readable format.
pub(crate) fn print_tags_human(tags: &[uteke_core::TagInfo], _by_count: bool) {
if tags.is_empty() {
Expand Down
39 changes: 39 additions & 0 deletions crates/uteke-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod maintenance;
pub mod memory;
pub mod offline_extraction;
mod operations;
pub mod pack_mode;

pub use operations::RememberOutcome;
mod orphans;
Expand Down Expand Up @@ -2224,6 +2225,44 @@ impl Uteke {
Ok(results)
}

/// Budgeted context pack over unified recall (#1281 Phase 1).
///
/// Runs the normal unified recall, then greedily selects results into a
/// caller-supplied character budget (see [`pack_mode`]). Deterministic,
/// LLM-free, rank-order preserving. `exclude_ids` are memory IDs the
/// caller already injected this turn; they come back as `skipped` with
/// reason `excluded`.
#[allow(clippy::too_many_arguments)]
pub fn recall_unified_packed(
&self,
query: &str,
limit: usize,
tags_filter: Option<&[&str]>,
namespace: Option<&str>,
min_score: f32,
search_type: SearchType,
entity_filter: Option<&str>,
category_filter: Option<&str>,
enrich: bool,
strategy: RecallStrategy,
budget_chars: usize,
exclude_ids: &[String],
) -> Result<pack_mode::ContextPack, Error> {
let results = self.recall_unified(
query,
limit,
tags_filter,
namespace,
min_score,
search_type,
entity_filter,
category_filter,
enrich,
strategy,
)?;
Ok(pack_mode::pack_context(results, budget_chars, exclude_ids))
}

#[allow(clippy::too_many_arguments)]
/// Unified search — memories only (backward-compatible path).
fn recall_unified_memories(
Expand Down
183 changes: 183 additions & 0 deletions crates/uteke-core/src/pack_mode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//! Budgeted context packing for recall results (#1281 Phase 1).
//!
//! Deterministic and LLM-free: greedily fill a character budget from the
//! ranked recall list, honouring caller-supplied `exclude_ids`. Scores and
//! rank order are untouched — this is a selection primitive, not a
//! re-ranker. (MMR diversity re-ranking is the Phase 2 experiment, gated
//! on LongMemEval + redundancy benchmarks before it can ship.)

use serde::{Deserialize, Serialize};

use crate::memory::types::UnifiedSearchResult;

/// Per-item overhead estimate (JSON envelope fields, separators), in chars.
const ITEM_OVERHEAD_CHARS: usize = 24;

/// Preview length for skipped items, so the report stays small.
const SKIPPED_PREVIEW_CHARS: usize = 160;

/// Result of packing recall results into a character budget (#1281).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextPack {
/// Items selected for the context window, in rank order.
pub selected: Vec<UnifiedSearchResult>,
/// Items not selected, in rank order, each with a reason.
pub skipped: Vec<SkippedItem>,
/// Estimated characters of selected content (incl. per-item overhead).
pub budget_used: usize,
/// The budget that was applied.
pub budget_chars: usize,
}

/// A recall result left out of the pack, with the reason.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkippedItem {
/// Content preview (truncated) of the skipped item.
pub content: String,
/// Why it was left out: `excluded` (caller exclude list) or `budget`.
pub reason: String,
/// Memory ID when the item is a memory.
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_id: Option<String>,
}

/// Pack ranked recall results into `budget_chars` of content.
///
/// `exclude_ids` are memory IDs the caller already injected this turn;
/// they are dropped first (reason `excluded`). Remaining items are taken
/// in rank order while they fit the remaining budget; items that do not
/// fit are reported as `skipped` (reason `budget`) and scanning continues,
/// so a cheaper lower-ranked item can still fill residual space. Rank
/// order is preserved end-to-end — no knapsack shuffling.
pub fn pack_context(
results: Vec<UnifiedSearchResult>,
budget_chars: usize,
exclude_ids: &[String],
) -> ContextPack {
let mut selected = Vec::new();
let mut skipped = Vec::new();
let mut used = 0usize;

for r in results {
let id = r.memory_id.clone();
if id
.as_deref()
.is_some_and(|id| exclude_ids.iter().any(|x| x == id))
{
skipped.push(SkippedItem {
content: preview(&r.content),
reason: "excluded".to_string(),
memory_id: id,
});
continue;
}
let cost = r.content.chars().count() + ITEM_OVERHEAD_CHARS;
if used + cost > budget_chars {
skipped.push(SkippedItem {
content: preview(&r.content),
reason: "budget".to_string(),
memory_id: id,
});
continue;
}
used += cost;
selected.push(r);
}

ContextPack {
selected,
skipped,
budget_used: used,
budget_chars,
}
}

fn preview(s: &str) -> String {
if s.chars().count() <= SKIPPED_PREVIEW_CHARS {
s.to_string()
} else {
let t: String = s.chars().take(SKIPPED_PREVIEW_CHARS).collect();
format!("{t}…")
}
}

#[cfg(test)]
mod tests {
use super::*;

fn ur(id: &str, content: &str, score: f32) -> UnifiedSearchResult {
serde_json::from_value(serde_json::json!({
"result_type": "memory",
"score": score,
"content": content,
"memory_id": id,
}))
.unwrap()
}

#[test]
fn fills_in_rank_order_until_budget() {
let results = vec![
ur("m1", &"a".repeat(50), 0.9),
ur("m2", &"b".repeat(50), 0.8),
ur("m3", &"c".repeat(50), 0.7),
];
// Budget fits exactly two items (2 × (50 + 24) = 148).
let pack = pack_context(results, 148, &[]);
assert_eq!(pack.selected.len(), 2);
assert_eq!(pack.selected[0].memory_id.as_deref(), Some("m1"));
assert_eq!(pack.selected[1].memory_id.as_deref(), Some("m2"));
assert_eq!(pack.skipped.len(), 1);
assert_eq!(pack.skipped[0].reason, "budget");
assert_eq!(pack.skipped[0].memory_id.as_deref(), Some("m3"));
assert_eq!(pack.budget_used, 148);
assert!(pack.budget_used <= pack.budget_chars);
}

#[test]
fn excluded_ids_are_dropped_with_reason() {
let results = vec![ur("m1", "alpha", 0.9), ur("m2", "beta", 0.8)];
let pack = pack_context(results, 10_000, &["m1".to_string()]);
assert_eq!(pack.selected.len(), 1);
assert_eq!(pack.selected[0].memory_id.as_deref(), Some("m2"));
assert_eq!(pack.skipped.len(), 1);
assert_eq!(pack.skipped[0].reason, "excluded");
assert_eq!(pack.skipped[0].memory_id.as_deref(), Some("m1"));
}

#[test]
fn zero_budget_selects_nothing() {
let results = vec![ur("m1", "alpha", 0.9)];
let pack = pack_context(results, 0, &[]);
assert!(pack.selected.is_empty());
assert_eq!(pack.skipped.len(), 1);
assert_eq!(pack.skipped[0].reason, "budget");
assert_eq!(pack.budget_used, 0);
}

#[test]
fn residual_budget_allows_cheaper_lower_ranked_item() {
let results = vec![
ur("big", &"x".repeat(90), 0.9),
ur("small", &"y".repeat(10), 0.5),
];
// Budget 120: big costs 114 and fits; small costs 34 → skipped.
let pack = pack_context(results.clone(), 120, &[]);
assert_eq!(pack.selected.len(), 1);
// Budget 115: big would exceed (0 + 114 fits? 114 ≤ 115 fits) — use
// 110 so big is skipped (114 > 110) and small (34) still fits.
let pack2 = pack_context(results, 110, &[]);
assert_eq!(pack2.selected.len(), 1);
assert_eq!(pack2.selected[0].memory_id.as_deref(), Some("small"));
assert_eq!(pack2.skipped[0].memory_id.as_deref(), Some("big"));
let _ = pack; // first assertion set already checked
}

#[test]
fn multibyte_content_counted_by_chars() {
let results = vec![ur("m1", "é".repeat(30).as_str(), 0.9)];
let pack = pack_context(results, 10_000, &[]);
assert_eq!(pack.selected.len(), 1);
assert_eq!(pack.budget_used, 30 + ITEM_OVERHEAD_CHARS);
}
}
Loading
Loading