Skip to main content

spool/
wakeup.rs

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    // Check pending review backlog
124    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    // Check staleness
135    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    // Inject lifecycle candidates into the appropriate sections.
331    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}