1use crate::config::ProjectConfig;
2use crate::domain::{
3 ContextBundle, ScoredNote, WakeupIdentity, WakeupMemoryItem, WakeupPacket, WakeupProfile,
4 WakeupProvenance, WakeupProvenanceSource, WakeupQuery, WakeupRecommendedNote, WakeupSection,
5};
6use crate::wakeup_policy::WakeupPolicyState;
7use std::path::Path;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10const DEFAULT_DEVELOPER_ROOTS: &[&str] = &["00-Identity", "20-Areas", "30-Workflows"];
11
12fn effective_developer_roots(configured: &[String]) -> Vec<String> {
13 if configured.is_empty() {
14 DEFAULT_DEVELOPER_ROOTS
15 .iter()
16 .map(|value| value.to_string())
17 .collect()
18 } else {
19 configured.to_vec()
20 }
21}
22
23fn build_identity(
24 bundle: &ContextBundle,
25 project_config: Option<&ProjectConfig>,
26 developer_roots: &[String],
27 profile: WakeupProfile,
28) -> WakeupIdentity {
29 WakeupIdentity {
30 project_id: bundle
31 .route
32 .project
33 .as_ref()
34 .map(|project| project.id.clone()),
35 project_name: bundle
36 .route
37 .project
38 .as_ref()
39 .map(|project| project.name.clone()),
40 repo_paths: project_config
41 .map(|project| {
42 project
43 .repo_paths
44 .iter()
45 .map(|path| path.display().to_string())
46 .collect()
47 })
48 .unwrap_or_default(),
49 modules: bundle
50 .route
51 .modules
52 .iter()
53 .map(|module| module.id.clone())
54 .collect(),
55 scenes: bundle
56 .route
57 .scenes
58 .iter()
59 .map(|scene| scene.id.clone())
60 .collect(),
61 active_profile: match profile {
62 WakeupProfile::Developer => "developer",
63 WakeupProfile::Project => "project",
64 }
65 .to_string(),
66 developer_roots: effective_developer_roots(developer_roots),
67 }
68}
69
70fn apply_selection_basis(
71 selection_basis: &mut Vec<String>,
72 bundle: &ContextBundle,
73 developer_roots: &[String],
74 profile: WakeupProfile,
75) {
76 let headline = match profile {
77 WakeupProfile::Project => bundle
78 .route
79 .project
80 .as_ref()
81 .map(|project| format!("project matched {}", project.id))
82 .unwrap_or_else(|| "project profile active".to_string()),
83 WakeupProfile::Developer => format!(
84 "developer roots active {}",
85 effective_developer_roots(developer_roots).join(", ")
86 ),
87 };
88 selection_basis.insert(0, headline);
89}
90
91fn build_priorities(
92 constraints: &[WakeupMemoryItem],
93 active_context: &[WakeupMemoryItem],
94 working_style: &[WakeupMemoryItem],
95 _profile: WakeupProfile,
96) -> Vec<String> {
97 let mut priorities = constraints
98 .iter()
99 .take(3)
100 .map(|item| item.title.clone())
101 .chain(active_context.iter().take(2).map(|item| item.title.clone()))
102 .collect::<Vec<_>>();
103
104 if priorities.is_empty() {
105 priorities.extend(working_style.iter().take(3).map(|item| item.title.clone()));
106 }
107
108 priorities
109}
110
111const STALENESS_DAYS: u64 = 180;
112const STALENESS_EXEMPT_TYPES: &[&str] = &["constraint", "preference"];
113
114fn build_maintenance_hints(lifecycle_root: Option<&Path>) -> Vec<String> {
115 let Some(root) = lifecycle_root else {
116 return Vec::new();
117 };
118 let store = crate::lifecycle_store::LifecycleStore::new(root);
119 let entries = store.read_all().unwrap_or_default();
120
121 let mut hints = Vec::new();
122
123 let pending_count = entries
125 .iter()
126 .filter(|e| e.record.state == crate::domain::MemoryLifecycleState::Candidate)
127 .count();
128 if pending_count >= 5 {
129 hints.push(format!(
130 "{pending_count} 条 AI 提议待审核,建议运行 memory list --view pending-review 查看"
131 ));
132 }
133
134 let ref_map = crate::reference_tracker::read(root);
136 if !ref_map.records.is_empty() {
137 let active_ids: std::collections::HashSet<&str> = entries
138 .iter()
139 .filter(|e| {
140 matches!(
141 e.record.state,
142 crate::domain::MemoryLifecycleState::Accepted
143 | crate::domain::MemoryLifecycleState::Canonical
144 ) && !STALENESS_EXEMPT_TYPES.contains(&e.record.memory_type.as_str())
145 })
146 .map(|e| e.record_id.as_str())
147 .collect();
148
149 let stale_count = ref_map
150 .records
151 .iter()
152 .filter(|(id, entry)| {
153 active_ids.contains(id.as_str())
154 && crate::reference_tracker::age_days(entry)
155 .is_some_and(|days| days >= STALENESS_DAYS)
156 })
157 .count();
158
159 if stale_count > 0 {
160 hints.push(format!(
161 "{stale_count} 条记忆超过 {STALENESS_DAYS} 天未引用,建议运行 memory lint 审查"
162 ));
163 }
164 }
165
166 hints
167}
168
169fn ensure_developer_sections(
170 profile: WakeupProfile,
171 working_style: &mut Vec<WakeupMemoryItem>,
172 active_context: &mut Vec<WakeupMemoryItem>,
173 recommended_notes: &[WakeupRecommendedNote],
174) {
175 if !matches!(profile, WakeupProfile::Developer) {
176 return;
177 }
178
179 if working_style.is_empty()
180 && let Some(note) = recommended_notes.first()
181 {
182 working_style.push(WakeupMemoryItem {
183 title: note.title.clone(),
184 summary: note.why_relevant.clone(),
185 memory_type: note.memory_type.clone(),
186 source: note.path.clone(),
187 sensitivity: None,
188 source_of_truth: false,
189 confidence: note.confidence,
190 });
191 }
192
193 if active_context.is_empty()
194 && let Some(note) = recommended_notes
195 .get(1)
196 .or_else(|| recommended_notes.first())
197 {
198 active_context.push(WakeupMemoryItem {
199 title: note.title.clone(),
200 summary: note.why_relevant.clone(),
201 memory_type: note.memory_type.clone(),
202 source: note.path.clone(),
203 sensitivity: None,
204 source_of_truth: false,
205 confidence: note.confidence,
206 });
207 }
208}
209
210fn push_limited<T>(items: &mut Vec<T>, item: T, limit: usize) {
211 if items.len() < limit {
212 items.push(item);
213 }
214}
215
216fn push_recommended(
217 items: &mut Vec<WakeupRecommendedNote>,
218 item: WakeupRecommendedNote,
219 limit: usize,
220) {
221 if items.len() >= limit {
222 return;
223 }
224 if items.iter().any(|existing| existing.path == item.path) {
225 return;
226 }
227 items.push(item);
228}
229
230fn generated_at_string() -> String {
231 let seconds = SystemTime::now()
232 .duration_since(UNIX_EPOCH)
233 .map(|duration| duration.as_secs())
234 .unwrap_or_default();
235 format!("unix:{seconds}")
236}
237
238pub fn build_packet(
239 bundle: &ContextBundle,
240 scored_notes: &[ScoredNote],
241 project_config: Option<&ProjectConfig>,
242 developer_roots: &[String],
243 profile: WakeupProfile,
244) -> WakeupPacket {
245 build_packet_with_index(
246 bundle,
247 scored_notes,
248 project_config,
249 developer_roots,
250 profile,
251 None,
252 None,
253 )
254}
255
256pub fn build_packet_with_index(
257 bundle: &ContextBundle,
258 scored_notes: &[ScoredNote],
259 project_config: Option<&ProjectConfig>,
260 developer_roots: &[String],
261 profile: WakeupProfile,
262 knowledge_index: Option<String>,
263 lifecycle_root: Option<&Path>,
264) -> WakeupPacket {
265 let mut working_style = Vec::new();
266 let mut active_context = Vec::new();
267 let mut constraints = Vec::new();
268 let mut decisions = Vec::new();
269 let mut incidents = Vec::new();
270 let mut recommended_notes = Vec::new();
271 let mut derived_from = Vec::new();
272 let mut selection_basis = Vec::new();
273 let mut policy_state = WakeupPolicyState::new();
274
275 for scored in scored_notes {
276 let note = &scored.note;
277 let prepared = policy_state.prepare_item(scored);
278 if prepared.suppressed {
279 continue;
280 }
281 let item = prepared.item;
282 let memory_type = item.memory_type.clone();
283 let source_of_truth = item.source_of_truth;
284
285 if derived_from
286 .iter()
287 .all(|source: &WakeupProvenanceSource| source.path != note.relative_path)
288 {
289 derived_from.push(WakeupProvenanceSource {
290 path: note.relative_path.clone(),
291 source_of_truth,
292 memory_type: memory_type.clone(),
293 });
294 }
295
296 for reason in &scored.reasons {
297 if !selection_basis.iter().any(|existing| existing == reason) {
298 selection_basis.push(reason.clone());
299 }
300 }
301
302 match memory_type.as_deref() {
303 Some("preference") | Some("workflow") => push_limited(&mut working_style, item, 5),
304 Some("project") => push_limited(&mut active_context, item, 5),
305 Some("constraint") => push_limited(&mut constraints, item, 5),
306 Some("decision") => push_limited(&mut decisions, item, 5),
307 Some("incident") => push_limited(&mut incidents, item, 3),
308 Some("session") => {}
309 _ => {
310 push_recommended(
311 &mut recommended_notes,
312 WakeupRecommendedNote {
313 path: note.relative_path.clone(),
314 title: note.title.clone(),
315 memory_type,
316 why_relevant: scored
317 .reasons
318 .first()
319 .cloned()
320 .unwrap_or_else(|| "matched retrieval query".to_string()),
321 score: scored.score,
322 confidence: scored.confidence,
323 },
324 8,
325 );
326 }
327 }
328 }
329
330 for candidate in &bundle.route.lifecycle_candidates {
332 let item = WakeupMemoryItem {
333 title: candidate.title.clone(),
334 summary: candidate.summary.clone(),
335 memory_type: Some(candidate.memory_type.clone()),
336 source: format!("ledger:{}", candidate.record_id),
337 sensitivity: None,
338 source_of_truth: false,
339 confidence: candidate.confidence,
340 };
341 match candidate.memory_type.as_str() {
342 "preference" | "workflow" => push_limited(&mut working_style, item, 5),
343 "project" => push_limited(&mut active_context, item, 5),
344 "constraint" => push_limited(&mut constraints, item, 5),
345 "decision" => push_limited(&mut decisions, item, 5),
346 "incident" => push_limited(&mut incidents, item, 3),
347 _ => {}
348 }
349 }
350
351 ensure_developer_sections(
352 profile,
353 &mut working_style,
354 &mut active_context,
355 &recommended_notes,
356 );
357 apply_selection_basis(&mut selection_basis, bundle, developer_roots, profile);
358
359 let priorities = build_priorities(&constraints, &active_context, &working_style, profile);
360
361 let maintenance_hints = build_maintenance_hints(lifecycle_root);
362
363 WakeupPacket {
364 version: "wakeup.v1".to_string(),
365 generated_at: generated_at_string(),
366 target: bundle.input.target,
367 profile,
368 query: WakeupQuery {
369 task: bundle.input.task.clone(),
370 cwd: bundle.input.cwd.display().to_string(),
371 files: bundle.input.files.clone(),
372 },
373 identity: build_identity(bundle, project_config, developer_roots, profile),
374 knowledge_index,
375 working_style: WakeupSection {
376 items: working_style,
377 },
378 active_context: WakeupSection {
379 items: active_context,
380 },
381 priorities,
382 constraints,
383 decisions,
384 incidents,
385 recommended_notes,
386 maintenance_hints,
387 provenance: WakeupProvenance {
388 derived_from,
389 selection_basis,
390 },
391 policy: policy_state.build_policy(),
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::build_packet;
398 use crate::config::{ProjectConfig, VaultLimits};
399 use crate::domain::{
400 CandidateNote, ConfidenceTier, ContextBundle, DebugTrace, MatchedProject, Note,
401 OutputFormat, RouteInput, RouteResult, Section, TargetTool, WakeupProfile,
402 };
403 use serde_json::json;
404 use std::collections::BTreeMap;
405 use std::path::PathBuf;
406
407 fn make_note(relative_path: &str, title: &str, memory_type: &str, sensitivity: &str) -> Note {
408 let mut frontmatter = BTreeMap::new();
409 frontmatter.insert("memory_type".to_string(), json!(memory_type));
410 frontmatter.insert("sensitivity".to_string(), json!(sensitivity));
411 frontmatter.insert("source_of_truth".to_string(), json!(true));
412 Note::new(
413 PathBuf::from(relative_path),
414 relative_path.to_string(),
415 title.to_string(),
416 frontmatter,
417 vec![Section {
418 heading: Some(title.to_string()),
419 level: 1,
420 content: format!("{title} body"),
421 }],
422 Vec::new(),
423 format!("{title} body"),
424 )
425 }
426
427 #[test]
428 fn wakeup_should_map_memory_types_into_packet_sections() {
429 let notes = [
430 make_note("pref.md", "Preference", "preference", "internal"),
431 make_note("workflow.md", "Workflow", "workflow", "internal"),
432 make_note("project.md", "Project", "project", "internal"),
433 make_note("constraint.md", "Constraint", "constraint", "internal"),
434 make_note("decision.md", "Decision", "decision", "internal"),
435 make_note("incident.md", "Incident", "incident", "internal"),
436 make_note("pattern.md", "Pattern", "pattern", "internal"),
437 make_note("session.md", "Session", "session", "internal"),
438 ];
439 let bundle = make_bundle(
440 notes
441 .iter()
442 .map(|note| CandidateNote {
443 relative_path: note.relative_path.clone(),
444 title: note.title.clone(),
445 score: 10,
446 reasons: vec!["matched task token".to_string()],
447 score_breakdown: Vec::new(),
448 confidence: ConfidenceTier::Medium,
449 excerpt: note.raw_content.clone(),
450 memory_type: note.memory_type().map(ToString::to_string),
451 sensitivity: note.sensitivity().map(ToString::to_string),
452 source_of_truth: note.source_of_truth(),
453 })
454 .collect(),
455 );
456 let scored_notes = notes
457 .iter()
458 .map(|note| note.to_scored(10, vec!["matched task token".to_string()]))
459 .collect::<Vec<_>>();
460 let packet = build_packet(
461 &bundle,
462 &scored_notes,
463 Some(&make_project_config()),
464 &[],
465 WakeupProfile::Project,
466 );
467
468 assert_eq!(packet.working_style.items.len(), 2);
469 assert_eq!(packet.active_context.items.len(), 1);
470 assert_eq!(packet.constraints.len(), 1);
471 assert_eq!(packet.decisions.len(), 1);
472 assert_eq!(packet.incidents.len(), 1);
473 assert_eq!(packet.recommended_notes.len(), 1);
474 assert!(
475 packet
476 .recommended_notes
477 .iter()
478 .all(|item| item.path != "session.md")
479 );
480 }
481
482 #[test]
483 fn wakeup_policy_should_redact_confidential_content() {
484 let confidential = make_note(
485 "confidential.md",
486 "Confidential",
487 "constraint",
488 "confidential",
489 );
490 let bundle = make_bundle(vec![CandidateNote {
491 relative_path: confidential.relative_path.clone(),
492 title: confidential.title.clone(),
493 score: 18,
494 reasons: vec!["confidential reason".to_string()],
495 score_breakdown: Vec::new(),
496 confidence: ConfidenceTier::Medium,
497 excerpt: "Highly specific confidential implementation details that should not be exposed verbatim.".to_string(),
498 memory_type: confidential.memory_type().map(ToString::to_string),
499 sensitivity: confidential.sensitivity().map(ToString::to_string),
500 source_of_truth: confidential.source_of_truth(),
501 }]);
502 let packet = build_packet(
503 &bundle,
504 &[confidential.to_scored(18, vec!["confidential reason".to_string()])],
505 Some(&make_project_config()),
506 &[],
507 WakeupProfile::Project,
508 );
509
510 assert_eq!(
511 packet.policy.max_sensitivity_included.as_deref(),
512 Some("confidential")
513 );
514 assert!(packet.policy.redactions_applied);
515 assert!(packet.constraints[0].summary.contains("[redacted]"));
516 }
517
518 #[test]
519 fn wakeup_policy_should_suppress_secret_notes_and_report_counts() {
520 let visible = make_note("visible.md", "Visible", "constraint", "internal");
521 let secret = make_note("secret.md", "Secret", "constraint", "secret");
522 let notes = [visible.clone(), secret.clone()];
523 let bundle = make_bundle(vec![
524 CandidateNote {
525 relative_path: visible.relative_path.clone(),
526 title: visible.title.clone(),
527 score: 12,
528 reasons: vec!["visible reason".to_string()],
529 score_breakdown: Vec::new(),
530 confidence: ConfidenceTier::Medium,
531 excerpt: visible.raw_content.clone(),
532 memory_type: visible.memory_type().map(ToString::to_string),
533 sensitivity: visible.sensitivity().map(ToString::to_string),
534 source_of_truth: visible.source_of_truth(),
535 },
536 CandidateNote {
537 relative_path: secret.relative_path.clone(),
538 title: secret.title.clone(),
539 score: 20,
540 reasons: vec!["secret reason".to_string()],
541 score_breakdown: Vec::new(),
542 confidence: ConfidenceTier::Medium,
543 excerpt: secret.raw_content.clone(),
544 memory_type: secret.memory_type().map(ToString::to_string),
545 sensitivity: secret.sensitivity().map(ToString::to_string),
546 source_of_truth: secret.source_of_truth(),
547 },
548 ]);
549 let scored_notes = notes
550 .iter()
551 .zip([12, 20])
552 .zip([
553 vec!["visible reason".to_string()],
554 vec!["secret reason".to_string()],
555 ])
556 .map(|((note, score), reasons)| note.to_scored(score, reasons))
557 .collect::<Vec<_>>();
558 let packet = build_packet(
559 &bundle,
560 &scored_notes,
561 Some(&make_project_config()),
562 &[],
563 WakeupProfile::Project,
564 );
565
566 assert_eq!(packet.policy.suppressed_note_count, 1);
567 assert!(
568 packet
569 .constraints
570 .iter()
571 .all(|item| item.source != "secret.md")
572 );
573 assert_eq!(
574 packet.policy.max_sensitivity_included.as_deref(),
575 Some("internal")
576 );
577 }
578
579 #[test]
580 fn wakeup_should_preserve_provenance_for_promoted_items() {
581 let note = make_note("constraint.md", "Constraint", "constraint", "internal");
582 let bundle = make_bundle(vec![CandidateNote {
583 relative_path: note.relative_path.clone(),
584 title: note.title.clone(),
585 score: 15,
586 reasons: vec!["source_of_truth boosted retrieval".to_string()],
587 score_breakdown: Vec::new(),
588 confidence: ConfidenceTier::Medium,
589 excerpt: note.raw_content.clone(),
590 memory_type: note.memory_type().map(ToString::to_string),
591 sensitivity: note.sensitivity().map(ToString::to_string),
592 source_of_truth: note.source_of_truth(),
593 }]);
594 let packet = build_packet(
595 &bundle,
596 &[note.to_scored(15, vec!["source_of_truth boosted retrieval".to_string()])],
597 Some(&make_project_config()),
598 &[],
599 WakeupProfile::Project,
600 );
601
602 assert_eq!(packet.provenance.derived_from.len(), 1);
603 assert_eq!(packet.provenance.derived_from[0].path, "constraint.md");
604 assert!(
605 packet
606 .provenance
607 .selection_basis
608 .iter()
609 .any(|reason| reason.contains("source_of_truth"))
610 );
611 }
612
613 #[test]
614 fn developer_packet_should_use_developer_roots_and_fill_sections() {
615 let preference = make_note(
616 "00-Identity/preferences.md",
617 "偏好",
618 "preference",
619 "internal",
620 );
621 let project = make_note("10-Projects/spool.md", "项目", "project", "internal");
622 let bundle = make_bundle(vec![
623 CandidateNote {
624 relative_path: preference.relative_path.clone(),
625 title: preference.title.clone(),
626 score: 12,
627 reasons: vec!["matched task token preference".to_string()],
628 score_breakdown: Vec::new(),
629 confidence: ConfidenceTier::Medium,
630 excerpt: preference.raw_content.clone(),
631 memory_type: preference.memory_type().map(ToString::to_string),
632 sensitivity: preference.sensitivity().map(ToString::to_string),
633 source_of_truth: preference.source_of_truth(),
634 },
635 CandidateNote {
636 relative_path: project.relative_path.clone(),
637 title: project.title.clone(),
638 score: 10,
639 reasons: vec!["matched project token".to_string()],
640 score_breakdown: Vec::new(),
641 confidence: ConfidenceTier::Medium,
642 excerpt: project.raw_content.clone(),
643 memory_type: project.memory_type().map(ToString::to_string),
644 sensitivity: project.sensitivity().map(ToString::to_string),
645 source_of_truth: project.source_of_truth(),
646 },
647 ]);
648 let scored_notes = vec![
649 preference.to_scored(12, vec!["matched task token preference".to_string()]),
650 project.to_scored(10, vec!["matched project token".to_string()]),
651 ];
652 let packet = build_packet(
653 &bundle,
654 &scored_notes,
655 Some(&make_project_config()),
656 &["00-Identity".to_string(), "20-Areas".to_string()],
657 WakeupProfile::Developer,
658 );
659
660 assert_eq!(packet.identity.active_profile, "developer");
661 assert_eq!(packet.identity.developer_roots[0], "00-Identity");
662 assert!(!packet.working_style.items.is_empty());
663 assert!(!packet.active_context.items.is_empty());
664 }
665
666 fn make_bundle(candidates: Vec<CandidateNote>) -> ContextBundle {
667 ContextBundle {
668 input: RouteInput {
669 task: "design wakeup".to_string(),
670 cwd: PathBuf::from("/tmp/repo"),
671 files: vec!["src/app.rs".to_string()],
672 target: TargetTool::Claude,
673 format: OutputFormat::Json,
674 },
675 route: RouteResult {
676 project: Some(MatchedProject {
677 id: "spool".to_string(),
678 name: "spool".to_string(),
679 reason: "cwd matched repo_path".to_string(),
680 }),
681 modules: Vec::new(),
682 scenes: Vec::new(),
683 sources: candidates
684 .iter()
685 .map(|item| item.relative_path.clone())
686 .collect(),
687 candidates,
688 lifecycle_candidates: Vec::new(),
689 debug: DebugTrace {
690 matched_project_id: Some("spool".to_string()),
691 note_roots: vec!["10-Projects".to_string()],
692 scan_roots: vec!["10-Projects".to_string()],
693 limits: VaultLimits::default(),
694 note_count: 1,
695 },
696 crystallize_hint: None,
697 },
698 }
699 }
700
701 fn make_project_config() -> ProjectConfig {
702 ProjectConfig {
703 id: "spool".to_string(),
704 name: "spool".to_string(),
705 repo_paths: vec![PathBuf::from("/tmp/repo")],
706 note_roots: vec!["10-Projects".to_string()],
707 default_tags: Vec::new(),
708 modules: Vec::new(),
709 }
710 }
711}