use crate::config::Config;
use crate::{graph, search};
use serde::{Deserialize, Serialize};
const BATTLE_CARD_CHAR_BUDGET: usize = 7_000;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BattleCard {
pub people: Vec<String>,
pub open_commitments: Vec<String>,
pub decisions: Vec<String>,
pub intents: Vec<String>,
pub fts_excerpts: Vec<String>,
pub rendered: String,
}
#[derive(Debug, thiserror::Error)]
pub enum BattleCardError {
#[error("battle-card sources were unavailable: {0}")]
SourcesUnavailable(String),
}
pub trait GroundingSource: Send + Sync + 'static {
fn refresh(&self, query: &str) -> Result<BattleCard, BattleCardError>;
}
#[derive(Debug, Clone)]
pub struct RepositoryGrounding {
config: Config,
}
impl RepositoryGrounding {
pub fn new(config: Config) -> Self {
Self { config }
}
}
impl GroundingSource for RepositoryGrounding {
fn refresh(&self, query: &str) -> Result<BattleCard, BattleCardError> {
BattleCard::assemble(&self.config, query)
}
}
impl BattleCard {
pub fn empty() -> Self {
Self {
rendered: "No historical context was loaded.".into(),
..Self::default()
}
}
pub fn assemble(config: &Config, query: &str) -> Result<Self, BattleCardError> {
let mut card = Self::default();
let mut source_errors = Vec::new();
let graph_ready = match graph::rebuild_index(config) {
Ok(_) => true,
Err(error) => {
source_errors.push(format!("graph rebuild: {error}"));
false
}
};
if graph_ready {
match graph::relationship_map(config) {
Ok(people) => {
card.people = people
.into_iter()
.take(8)
.map(|person| {
let topics = if person.top_topics.is_empty() {
"no recurring topics".to_string()
} else {
person.top_topics.join(", ")
};
format!(
"{} — {} prior meetings; topics: {}",
person.name, person.meeting_count, topics
)
})
.collect();
}
Err(error) => source_errors.push(format!("graph people: {error}")),
}
}
if graph_ready {
match graph::query_commitments(config, None) {
Ok(commitments) => {
card.open_commitments = commitments
.into_iter()
.take(10)
.map(|commitment| {
let owner = commitment
.person_name
.as_deref()
.unwrap_or("owner not verified");
let due = commitment
.due_date
.as_deref()
.map(|date| format!("; due {date}"))
.unwrap_or_default();
format!(
"{}: {}{} (from {})",
owner, commitment.text, due, commitment.meeting_title
)
})
.collect();
}
Err(error) => source_errors.push(format!("graph commitments: {error}")),
}
}
let filters = search::SearchFilters::default();
match search::search_intents("", config, &filters) {
Ok(intents) => {
card.intents = intents
.into_iter()
.filter(|intent| intent.status == "open")
.take(10)
.map(|intent| {
let owner = intent.who.as_deref().unwrap_or("owner not verified");
format!(
"{:?}: {} — {} ({})",
intent.kind, intent.what, owner, intent.title
)
})
.collect();
}
Err(error) => source_errors.push(format!("structured intents: {error}")),
}
match search::cross_meeting_research(query, config, &filters) {
Ok(research) => {
card.decisions = research
.related_decisions
.into_iter()
.take(8)
.map(|decision| format!("{} ({})", decision.what, decision.title))
.collect();
card.intents
.extend(
research
.related_open_intents
.into_iter()
.take(5)
.map(|intent| {
let owner = intent.who.as_deref().unwrap_or("owner not verified");
format!(
"{:?}: {} — {} ({})",
intent.kind, intent.what, owner, intent.title
)
}),
);
dedupe(&mut card.intents);
card.intents.truncate(10);
}
Err(error) => source_errors.push(format!("decision research: {error}")),
}
if !query.trim().is_empty() {
match search::search(query, config, &filters) {
Ok(results) => {
card.fts_excerpts = results
.into_iter()
.take(6)
.map(|result| {
format!("{} ({}): {}", result.title, result.date, result.snippet)
})
.collect();
}
Err(error) => source_errors.push(format!("FTS: {error}")),
}
}
if card.people.is_empty()
&& card.open_commitments.is_empty()
&& card.decisions.is_empty()
&& card.intents.is_empty()
&& card.fts_excerpts.is_empty()
&& !source_errors.is_empty()
{
return Err(BattleCardError::SourcesUnavailable(
source_errors.join("; "),
));
}
card.rendered = render_bounded(&card);
if card.rendered.is_empty() {
card.rendered = "No relevant unrestricted historical context was found.".into();
}
Ok(card)
}
}
fn dedupe(values: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
values.retain(|value| seen.insert(value.to_ascii_lowercase()));
}
fn render_bounded(card: &BattleCard) -> String {
let mut output = String::new();
append_section(&mut output, "People", &card.people);
append_section(&mut output, "Open commitments", &card.open_commitments);
append_section(&mut output, "Recent decisions", &card.decisions);
append_section(&mut output, "Open intents", &card.intents);
append_section(&mut output, "Relevant excerpts", &card.fts_excerpts);
output
}
fn append_section(output: &mut String, heading: &str, values: &[String]) {
if values.is_empty() || output.len() >= BATTLE_CARD_CHAR_BUDGET {
return;
}
let heading_line = format!("## {heading}\n");
if output.len() + heading_line.len() <= BATTLE_CARD_CHAR_BUDGET {
output.push_str(&heading_line);
}
for value in values {
let line = format!("- {value}\n");
if output.len() + line.len() > BATTLE_CARD_CHAR_BUDGET {
break;
}
output.push_str(&line);
}
if output.len() < BATTLE_CARD_CHAR_BUDGET {
output.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copilot::{
CopilotRequest, CopilotUtterance, MeetingMode, StrategyRefreshReason, StrategyRequest,
StrategyState, TranscriptUpdateKind,
};
use std::ffi::OsString;
use std::path::Path;
struct HomeOverride {
previous: Option<OsString>,
}
impl HomeOverride {
fn set(path: &Path) -> Self {
let previous = std::env::var_os("HOME");
std::env::set_var("HOME", path);
Self { previous }
}
}
impl Drop for HomeOverride {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
std::env::set_var("HOME", previous);
} else {
std::env::remove_var("HOME");
}
}
}
fn meeting(title: &str, person: &str, secret: &str, restricted: bool) -> String {
let sensitivity = if restricted {
"sensitivity: restricted\n"
} else {
""
};
format!(
"---\ntitle: {title}\ntype: meeting\ndate: 2026-06-11T12:00:00+00:00\nduration: 30m\nstatus: complete\n{sensitivity}attendees: [{person}]\npeople: [{person}]\naction_items:\n - assignee: {person}\n task: {secret}\n status: open\ndecisions:\n - text: {secret}\n topic: pricing\nintents:\n - kind: commitment\n what: {secret}\n who: {person}\n status: open\n---\n\n## Transcript\n\n{secret}\n"
)
}
#[test]
fn assembly_excludes_restricted_history_from_graph_structured_and_fts_sources() {
let _guard = crate::test_home_env_lock();
let temp = tempfile::tempdir().unwrap();
let _home = HomeOverride::set(temp.path());
let meetings = temp.path().join("meetings");
std::fs::create_dir_all(&meetings).unwrap();
std::fs::write(
meetings.join("normal.md"),
meeting(
"Pricing Sync",
"Sam Lee",
"Share the public pricing deck",
false,
),
)
.unwrap();
std::fs::write(
meetings.join("restricted.md"),
meeting(
"Board Pricing",
"Alex Kim",
"SECRET board pricing floor",
true,
),
)
.unwrap();
let mut config = Config::default();
config.output_dir = meetings;
let card = BattleCard::assemble(&config, "pricing").unwrap();
assert!(card.rendered.contains("Sam Lee"));
assert!(card.rendered.contains("public pricing deck"));
assert!(!card.rendered.contains("Alex Kim"));
assert!(!card.rendered.contains("SECRET"));
assert!(!card.rendered.contains("Board Pricing"));
assert!(card.rendered.len() <= BATTLE_CARD_CHAR_BUDGET);
let serialized_card = serde_json::to_string(&card).unwrap();
assert!(!serialized_card.contains("Alex Kim"));
assert!(!serialized_card.contains("SECRET"));
assert!(!serialized_card.contains("Board Pricing"));
let utterance = CopilotUtterance {
utterance_sequence: 1,
revision: 1,
update_kind: TranscriptUpdateKind::Final,
source: "live.utterance.final".into(),
text: "Can we confirm the public pricing deck owner?".into(),
speaker: None,
speaker_verified: false,
offset_ms: 0,
duration_ms: 100,
};
let fast_request = CopilotRequest {
goal: "Confirm the public pricing plan".into(),
mode: MeetingMode::Decision,
session_epoch: 1,
evidence_revision: 1,
evidence_utterance_sequence: 1,
evidence_utterance_revision: 1,
update_kind: TranscriptUpdateKind::Final,
utterances: vec![utterance.clone()],
battle_card: card.clone(),
strategy_state: StrategyState::empty(),
};
assert!(!fast_request.untrusted_payload().contains("SECRET"));
let depth_request = StrategyRequest {
goal: fast_request.goal.clone(),
mode: fast_request.mode,
evidence_revision: fast_request.evidence_revision,
reason: StrategyRefreshReason::Initial,
utterances: vec![utterance],
battle_card: card,
prior_state: StrategyState::empty(),
};
let depth_payload = depth_request.untrusted_payload();
assert!(!depth_payload.contains("Alex Kim"));
assert!(!depth_payload.contains("SECRET"));
assert!(!depth_payload.contains("Board Pricing"));
let strategy = StrategyState::from_draft(
depth_request.heuristic_draft(),
depth_request.evidence_revision,
);
let serialized_strategy = serde_json::to_string(&strategy).unwrap();
assert!(!serialized_strategy.contains("Alex Kim"));
assert!(!serialized_strategy.contains("SECRET"));
assert!(!serialized_strategy.contains("Board Pricing"));
}
}