use crate::config::ProjectConfig;
use crate::domain::{
ContextBundle, ScoredNote, WakeupIdentity, WakeupMemoryItem, WakeupPacket, WakeupProfile,
WakeupProvenance, WakeupProvenanceSource, WakeupQuery, WakeupRecommendedNote, WakeupSection,
};
use crate::wakeup_policy::WakeupPolicyState;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const DEFAULT_DEVELOPER_ROOTS: &[&str] = &["00-Identity", "20-Areas", "30-Workflows"];
fn effective_developer_roots(configured: &[String]) -> Vec<String> {
if configured.is_empty() {
DEFAULT_DEVELOPER_ROOTS
.iter()
.map(|value| value.to_string())
.collect()
} else {
configured.to_vec()
}
}
fn build_identity(
bundle: &ContextBundle,
project_config: Option<&ProjectConfig>,
developer_roots: &[String],
profile: WakeupProfile,
) -> WakeupIdentity {
WakeupIdentity {
project_id: bundle
.route
.project
.as_ref()
.map(|project| project.id.clone()),
project_name: bundle
.route
.project
.as_ref()
.map(|project| project.name.clone()),
repo_paths: project_config
.map(|project| {
project
.repo_paths
.iter()
.map(|path| path.display().to_string())
.collect()
})
.unwrap_or_default(),
modules: bundle
.route
.modules
.iter()
.map(|module| module.id.clone())
.collect(),
scenes: bundle
.route
.scenes
.iter()
.map(|scene| scene.id.clone())
.collect(),
active_profile: match profile {
WakeupProfile::Developer => "developer",
WakeupProfile::Project => "project",
}
.to_string(),
developer_roots: effective_developer_roots(developer_roots),
}
}
fn apply_selection_basis(
selection_basis: &mut Vec<String>,
bundle: &ContextBundle,
developer_roots: &[String],
profile: WakeupProfile,
) {
let headline = match profile {
WakeupProfile::Project => bundle
.route
.project
.as_ref()
.map(|project| format!("project matched {}", project.id))
.unwrap_or_else(|| "project profile active".to_string()),
WakeupProfile::Developer => format!(
"developer roots active {}",
effective_developer_roots(developer_roots).join(", ")
),
};
selection_basis.insert(0, headline);
}
fn build_priorities(
constraints: &[WakeupMemoryItem],
active_context: &[WakeupMemoryItem],
working_style: &[WakeupMemoryItem],
_profile: WakeupProfile,
) -> Vec<String> {
let mut priorities = constraints
.iter()
.take(3)
.map(|item| item.title.clone())
.chain(active_context.iter().take(2).map(|item| item.title.clone()))
.collect::<Vec<_>>();
if priorities.is_empty() {
priorities.extend(working_style.iter().take(3).map(|item| item.title.clone()));
}
priorities
}
const STALENESS_DAYS: u64 = 180;
const STALENESS_EXEMPT_TYPES: &[&str] = &["constraint", "preference"];
fn build_maintenance_hints(lifecycle_root: Option<&Path>) -> Vec<String> {
let Some(root) = lifecycle_root else {
return Vec::new();
};
let store = crate::lifecycle_store::LifecycleStore::new(root);
let entries = store.read_all().unwrap_or_default();
let mut hints = Vec::new();
let pending_count = entries
.iter()
.filter(|e| e.record.state == crate::domain::MemoryLifecycleState::Candidate)
.count();
if pending_count >= 5 {
hints.push(format!(
"{pending_count} 条 AI 提议待审核,建议运行 memory list --view pending-review 查看"
));
}
let ref_map = crate::reference_tracker::read(root);
if !ref_map.records.is_empty() {
let active_ids: std::collections::HashSet<&str> = entries
.iter()
.filter(|e| {
matches!(
e.record.state,
crate::domain::MemoryLifecycleState::Accepted
| crate::domain::MemoryLifecycleState::Canonical
) && !STALENESS_EXEMPT_TYPES.contains(&e.record.memory_type.as_str())
})
.map(|e| e.record_id.as_str())
.collect();
let stale_count = ref_map
.records
.iter()
.filter(|(id, entry)| {
active_ids.contains(id.as_str())
&& crate::reference_tracker::age_days(entry)
.is_some_and(|days| days >= STALENESS_DAYS)
})
.count();
if stale_count > 0 {
hints.push(format!(
"{stale_count} 条记忆超过 {STALENESS_DAYS} 天未引用,建议运行 memory lint 审查"
));
}
}
hints
}
fn ensure_developer_sections(
profile: WakeupProfile,
working_style: &mut Vec<WakeupMemoryItem>,
active_context: &mut Vec<WakeupMemoryItem>,
recommended_notes: &[WakeupRecommendedNote],
) {
if !matches!(profile, WakeupProfile::Developer) {
return;
}
if working_style.is_empty()
&& let Some(note) = recommended_notes.first()
{
working_style.push(WakeupMemoryItem {
title: note.title.clone(),
summary: note.why_relevant.clone(),
memory_type: note.memory_type.clone(),
source: note.path.clone(),
sensitivity: None,
source_of_truth: false,
confidence: note.confidence,
});
}
if active_context.is_empty()
&& let Some(note) = recommended_notes
.get(1)
.or_else(|| recommended_notes.first())
{
active_context.push(WakeupMemoryItem {
title: note.title.clone(),
summary: note.why_relevant.clone(),
memory_type: note.memory_type.clone(),
source: note.path.clone(),
sensitivity: None,
source_of_truth: false,
confidence: note.confidence,
});
}
}
fn push_limited<T>(items: &mut Vec<T>, item: T, limit: usize) {
if items.len() < limit {
items.push(item);
}
}
fn push_recommended(
items: &mut Vec<WakeupRecommendedNote>,
item: WakeupRecommendedNote,
limit: usize,
) {
if items.len() >= limit {
return;
}
if items.iter().any(|existing| existing.path == item.path) {
return;
}
items.push(item);
}
fn generated_at_string() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
format!("unix:{seconds}")
}
pub fn build_packet(
bundle: &ContextBundle,
scored_notes: &[ScoredNote],
project_config: Option<&ProjectConfig>,
developer_roots: &[String],
profile: WakeupProfile,
) -> WakeupPacket {
build_packet_with_index(
bundle,
scored_notes,
project_config,
developer_roots,
profile,
None,
None,
)
}
pub fn build_packet_with_index(
bundle: &ContextBundle,
scored_notes: &[ScoredNote],
project_config: Option<&ProjectConfig>,
developer_roots: &[String],
profile: WakeupProfile,
knowledge_index: Option<String>,
lifecycle_root: Option<&Path>,
) -> WakeupPacket {
let mut working_style = Vec::new();
let mut active_context = Vec::new();
let mut constraints = Vec::new();
let mut decisions = Vec::new();
let mut incidents = Vec::new();
let mut recommended_notes = Vec::new();
let mut derived_from = Vec::new();
let mut selection_basis = Vec::new();
let mut policy_state = WakeupPolicyState::new();
for scored in scored_notes {
let note = &scored.note;
let prepared = policy_state.prepare_item(scored);
if prepared.suppressed {
continue;
}
let item = prepared.item;
let memory_type = item.memory_type.clone();
let source_of_truth = item.source_of_truth;
if derived_from
.iter()
.all(|source: &WakeupProvenanceSource| source.path != note.relative_path)
{
derived_from.push(WakeupProvenanceSource {
path: note.relative_path.clone(),
source_of_truth,
memory_type: memory_type.clone(),
});
}
for reason in &scored.reasons {
if !selection_basis.iter().any(|existing| existing == reason) {
selection_basis.push(reason.clone());
}
}
match memory_type.as_deref() {
Some("preference") | Some("workflow") => push_limited(&mut working_style, item, 5),
Some("project") => push_limited(&mut active_context, item, 5),
Some("constraint") => push_limited(&mut constraints, item, 5),
Some("decision") => push_limited(&mut decisions, item, 5),
Some("incident") => push_limited(&mut incidents, item, 3),
Some("session") => {}
_ => {
push_recommended(
&mut recommended_notes,
WakeupRecommendedNote {
path: note.relative_path.clone(),
title: note.title.clone(),
memory_type,
why_relevant: scored
.reasons
.first()
.cloned()
.unwrap_or_else(|| "matched retrieval query".to_string()),
score: scored.score,
confidence: scored.confidence,
},
8,
);
}
}
}
for candidate in &bundle.route.lifecycle_candidates {
let item = WakeupMemoryItem {
title: candidate.title.clone(),
summary: candidate.summary.clone(),
memory_type: Some(candidate.memory_type.clone()),
source: format!("ledger:{}", candidate.record_id),
sensitivity: None,
source_of_truth: false,
confidence: candidate.confidence,
};
match candidate.memory_type.as_str() {
"preference" | "workflow" => push_limited(&mut working_style, item, 5),
"project" => push_limited(&mut active_context, item, 5),
"constraint" => push_limited(&mut constraints, item, 5),
"decision" => push_limited(&mut decisions, item, 5),
"incident" => push_limited(&mut incidents, item, 3),
_ => {}
}
}
ensure_developer_sections(
profile,
&mut working_style,
&mut active_context,
&recommended_notes,
);
apply_selection_basis(&mut selection_basis, bundle, developer_roots, profile);
let priorities = build_priorities(&constraints, &active_context, &working_style, profile);
let maintenance_hints = build_maintenance_hints(lifecycle_root);
WakeupPacket {
version: "wakeup.v1".to_string(),
generated_at: generated_at_string(),
target: bundle.input.target,
profile,
query: WakeupQuery {
task: bundle.input.task.clone(),
cwd: bundle.input.cwd.display().to_string(),
files: bundle.input.files.clone(),
},
identity: build_identity(bundle, project_config, developer_roots, profile),
knowledge_index,
working_style: WakeupSection {
items: working_style,
},
active_context: WakeupSection {
items: active_context,
},
priorities,
constraints,
decisions,
incidents,
recommended_notes,
maintenance_hints,
provenance: WakeupProvenance {
derived_from,
selection_basis,
},
policy: policy_state.build_policy(),
}
}
#[cfg(test)]
mod tests {
use super::build_packet;
use crate::config::{ProjectConfig, VaultLimits};
use crate::domain::{
CandidateNote, ConfidenceTier, ContextBundle, DebugTrace, MatchedProject, Note,
OutputFormat, RouteInput, RouteResult, Section, TargetTool, WakeupProfile,
};
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn make_note(relative_path: &str, title: &str, memory_type: &str, sensitivity: &str) -> Note {
let mut frontmatter = BTreeMap::new();
frontmatter.insert("memory_type".to_string(), json!(memory_type));
frontmatter.insert("sensitivity".to_string(), json!(sensitivity));
frontmatter.insert("source_of_truth".to_string(), json!(true));
Note::new(
PathBuf::from(relative_path),
relative_path.to_string(),
title.to_string(),
frontmatter,
vec![Section {
heading: Some(title.to_string()),
level: 1,
content: format!("{title} body"),
}],
Vec::new(),
format!("{title} body"),
)
}
#[test]
fn wakeup_should_map_memory_types_into_packet_sections() {
let notes = [
make_note("pref.md", "Preference", "preference", "internal"),
make_note("workflow.md", "Workflow", "workflow", "internal"),
make_note("project.md", "Project", "project", "internal"),
make_note("constraint.md", "Constraint", "constraint", "internal"),
make_note("decision.md", "Decision", "decision", "internal"),
make_note("incident.md", "Incident", "incident", "internal"),
make_note("pattern.md", "Pattern", "pattern", "internal"),
make_note("session.md", "Session", "session", "internal"),
];
let bundle = make_bundle(
notes
.iter()
.map(|note| CandidateNote {
relative_path: note.relative_path.clone(),
title: note.title.clone(),
score: 10,
reasons: vec!["matched task token".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: note.raw_content.clone(),
memory_type: note.memory_type().map(ToString::to_string),
sensitivity: note.sensitivity().map(ToString::to_string),
source_of_truth: note.source_of_truth(),
})
.collect(),
);
let scored_notes = notes
.iter()
.map(|note| note.to_scored(10, vec!["matched task token".to_string()]))
.collect::<Vec<_>>();
let packet = build_packet(
&bundle,
&scored_notes,
Some(&make_project_config()),
&[],
WakeupProfile::Project,
);
assert_eq!(packet.working_style.items.len(), 2);
assert_eq!(packet.active_context.items.len(), 1);
assert_eq!(packet.constraints.len(), 1);
assert_eq!(packet.decisions.len(), 1);
assert_eq!(packet.incidents.len(), 1);
assert_eq!(packet.recommended_notes.len(), 1);
assert!(
packet
.recommended_notes
.iter()
.all(|item| item.path != "session.md")
);
}
#[test]
fn wakeup_policy_should_redact_confidential_content() {
let confidential = make_note(
"confidential.md",
"Confidential",
"constraint",
"confidential",
);
let bundle = make_bundle(vec![CandidateNote {
relative_path: confidential.relative_path.clone(),
title: confidential.title.clone(),
score: 18,
reasons: vec!["confidential reason".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: "Highly specific confidential implementation details that should not be exposed verbatim.".to_string(),
memory_type: confidential.memory_type().map(ToString::to_string),
sensitivity: confidential.sensitivity().map(ToString::to_string),
source_of_truth: confidential.source_of_truth(),
}]);
let packet = build_packet(
&bundle,
&[confidential.to_scored(18, vec!["confidential reason".to_string()])],
Some(&make_project_config()),
&[],
WakeupProfile::Project,
);
assert_eq!(
packet.policy.max_sensitivity_included.as_deref(),
Some("confidential")
);
assert!(packet.policy.redactions_applied);
assert!(packet.constraints[0].summary.contains("[redacted]"));
}
#[test]
fn wakeup_policy_should_suppress_secret_notes_and_report_counts() {
let visible = make_note("visible.md", "Visible", "constraint", "internal");
let secret = make_note("secret.md", "Secret", "constraint", "secret");
let notes = [visible.clone(), secret.clone()];
let bundle = make_bundle(vec![
CandidateNote {
relative_path: visible.relative_path.clone(),
title: visible.title.clone(),
score: 12,
reasons: vec!["visible reason".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: visible.raw_content.clone(),
memory_type: visible.memory_type().map(ToString::to_string),
sensitivity: visible.sensitivity().map(ToString::to_string),
source_of_truth: visible.source_of_truth(),
},
CandidateNote {
relative_path: secret.relative_path.clone(),
title: secret.title.clone(),
score: 20,
reasons: vec!["secret reason".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: secret.raw_content.clone(),
memory_type: secret.memory_type().map(ToString::to_string),
sensitivity: secret.sensitivity().map(ToString::to_string),
source_of_truth: secret.source_of_truth(),
},
]);
let scored_notes = notes
.iter()
.zip([12, 20])
.zip([
vec!["visible reason".to_string()],
vec!["secret reason".to_string()],
])
.map(|((note, score), reasons)| note.to_scored(score, reasons))
.collect::<Vec<_>>();
let packet = build_packet(
&bundle,
&scored_notes,
Some(&make_project_config()),
&[],
WakeupProfile::Project,
);
assert_eq!(packet.policy.suppressed_note_count, 1);
assert!(
packet
.constraints
.iter()
.all(|item| item.source != "secret.md")
);
assert_eq!(
packet.policy.max_sensitivity_included.as_deref(),
Some("internal")
);
}
#[test]
fn wakeup_should_preserve_provenance_for_promoted_items() {
let note = make_note("constraint.md", "Constraint", "constraint", "internal");
let bundle = make_bundle(vec![CandidateNote {
relative_path: note.relative_path.clone(),
title: note.title.clone(),
score: 15,
reasons: vec!["source_of_truth boosted retrieval".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: note.raw_content.clone(),
memory_type: note.memory_type().map(ToString::to_string),
sensitivity: note.sensitivity().map(ToString::to_string),
source_of_truth: note.source_of_truth(),
}]);
let packet = build_packet(
&bundle,
&[note.to_scored(15, vec!["source_of_truth boosted retrieval".to_string()])],
Some(&make_project_config()),
&[],
WakeupProfile::Project,
);
assert_eq!(packet.provenance.derived_from.len(), 1);
assert_eq!(packet.provenance.derived_from[0].path, "constraint.md");
assert!(
packet
.provenance
.selection_basis
.iter()
.any(|reason| reason.contains("source_of_truth"))
);
}
#[test]
fn developer_packet_should_use_developer_roots_and_fill_sections() {
let preference = make_note(
"00-Identity/preferences.md",
"偏好",
"preference",
"internal",
);
let project = make_note("10-Projects/spool.md", "项目", "project", "internal");
let bundle = make_bundle(vec![
CandidateNote {
relative_path: preference.relative_path.clone(),
title: preference.title.clone(),
score: 12,
reasons: vec!["matched task token preference".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: preference.raw_content.clone(),
memory_type: preference.memory_type().map(ToString::to_string),
sensitivity: preference.sensitivity().map(ToString::to_string),
source_of_truth: preference.source_of_truth(),
},
CandidateNote {
relative_path: project.relative_path.clone(),
title: project.title.clone(),
score: 10,
reasons: vec!["matched project token".to_string()],
score_breakdown: Vec::new(),
confidence: ConfidenceTier::Medium,
excerpt: project.raw_content.clone(),
memory_type: project.memory_type().map(ToString::to_string),
sensitivity: project.sensitivity().map(ToString::to_string),
source_of_truth: project.source_of_truth(),
},
]);
let scored_notes = vec![
preference.to_scored(12, vec!["matched task token preference".to_string()]),
project.to_scored(10, vec!["matched project token".to_string()]),
];
let packet = build_packet(
&bundle,
&scored_notes,
Some(&make_project_config()),
&["00-Identity".to_string(), "20-Areas".to_string()],
WakeupProfile::Developer,
);
assert_eq!(packet.identity.active_profile, "developer");
assert_eq!(packet.identity.developer_roots[0], "00-Identity");
assert!(!packet.working_style.items.is_empty());
assert!(!packet.active_context.items.is_empty());
}
fn make_bundle(candidates: Vec<CandidateNote>) -> ContextBundle {
ContextBundle {
input: RouteInput {
task: "design wakeup".to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: vec!["src/app.rs".to_string()],
target: TargetTool::Claude,
format: OutputFormat::Json,
},
route: RouteResult {
project: Some(MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "cwd matched repo_path".to_string(),
}),
modules: Vec::new(),
scenes: Vec::new(),
sources: candidates
.iter()
.map(|item| item.relative_path.clone())
.collect(),
candidates,
lifecycle_candidates: Vec::new(),
debug: DebugTrace {
matched_project_id: Some("spool".to_string()),
note_roots: vec!["10-Projects".to_string()],
scan_roots: vec!["10-Projects".to_string()],
limits: VaultLimits::default(),
note_count: 1,
},
crystallize_hint: None,
},
}
}
fn make_project_config() -> ProjectConfig {
ProjectConfig {
id: "spool".to_string(),
name: "spool".to_string(),
repo_paths: vec![PathBuf::from("/tmp/repo")],
note_roots: vec!["10-Projects".to_string()],
default_tags: Vec::new(),
modules: Vec::new(),
}
}
}