Skip to main content

spool/output/
wakeup.rs

1use crate::domain::{
2    OutputFormat, TargetTool, WakeupMemoryItem, WakeupPacket, WakeupProfile, WakeupRecommendedNote,
3};
4
5pub fn render(packet: &WakeupPacket, format: OutputFormat) -> String {
6    match format {
7        OutputFormat::Json => render_json(packet),
8        OutputFormat::Markdown => render_markdown(packet),
9        OutputFormat::Prompt => render_prompt(packet),
10    }
11}
12
13pub fn render_json(packet: &WakeupPacket) -> String {
14    serde_json::to_string_pretty(packet).unwrap_or_else(|_| "{}".to_string())
15}
16
17pub fn render_markdown(packet: &WakeupPacket) -> String {
18    let mut output = String::new();
19    output.push_str("# wakeup packet\n\n");
20    output.push_str(&format!("- profile: {}\n", profile_label(packet.profile)));
21    output.push_str(&format!("- target: {}\n", target_label(packet.target)));
22    output.push_str(&format!("- task: {}\n", packet.query.task));
23    output.push_str(&format!("- cwd: {}\n", packet.query.cwd));
24    if let Some(project_name) = &packet.identity.project_name {
25        output.push_str(&format!("- project: {}\n", project_name));
26    }
27    if !packet.identity.developer_roots.is_empty() {
28        output.push_str(&format!(
29            "- developer_roots: {}\n",
30            packet.identity.developer_roots.join(", ")
31        ));
32    }
33    output.push('\n');
34
35    if let Some(index) = &packet.knowledge_index {
36        output.push_str("## Knowledge index\n\n");
37        output.push_str(index);
38        if !index.ends_with('\n') {
39            output.push('\n');
40        }
41        output.push('\n');
42    }
43
44    render_section(&mut output, "Working style", &packet.working_style.items);
45    render_section(&mut output, "Active context", &packet.active_context.items);
46    render_section(&mut output, "Constraints", &packet.constraints);
47    render_section(&mut output, "Decisions", &packet.decisions);
48    render_section(&mut output, "Incidents", &packet.incidents);
49    render_notes(&mut output, &packet.recommended_notes);
50
51    if !packet.maintenance_hints.is_empty() {
52        output.push_str("## Maintenance hints\n\n");
53        for hint in &packet.maintenance_hints {
54            output.push_str(&format!("- {}\n", hint));
55        }
56        output.push('\n');
57    }
58
59    output.push_str("## Policy\n\n");
60    output.push_str(&format!(
61        "- mode: {}\n- max_sensitivity_included: {}\n- redactions_applied: {}\n- suppressed_note_count: {}\n",
62        packet.policy.policy_mode,
63        packet
64            .policy
65            .max_sensitivity_included
66            .as_deref()
67            .unwrap_or("none"),
68        packet.policy.redactions_applied,
69        packet.policy.suppressed_note_count,
70    ));
71
72    output
73}
74
75pub fn render_prompt(packet: &WakeupPacket) -> String {
76    let mut output = String::new();
77    output.push_str(match packet.target {
78        TargetTool::Claude => "以下是给 Claude 使用的 wake-up packet。\n\n",
79        TargetTool::Codex => "以下是给 Codex 使用的 wake-up packet。\n\n",
80        TargetTool::Opencode => "以下是给 OpenCode 使用的 wake-up packet。\n\n",
81    });
82    output.push_str(&format!("Profile: {}\n", profile_label(packet.profile)));
83    if let Some(project_name) = &packet.identity.project_name {
84        output.push_str(&format!("Project: {}\n", project_name));
85    }
86    output.push_str(&format!("Task: {}\n", packet.query.task));
87
88    let total_items = packet.working_style.items.len()
89        + packet.active_context.items.len()
90        + packet.constraints.len()
91        + packet.decisions.len()
92        + packet.incidents.len();
93    if total_items > 0 {
94        output.push_str(&format!("Memories loaded: {total_items}\n"));
95    }
96    output.push('\n');
97
98    if let Some(index) = &packet.knowledge_index {
99        output.push_str("Knowledge index (auto-synthesized):\n");
100        output.push_str(index);
101        if !index.ends_with('\n') {
102            output.push('\n');
103        }
104        output.push('\n');
105    }
106
107    if !packet.working_style.items.is_empty() {
108        output.push_str("Working style:\n");
109        for item in &packet.working_style.items {
110            output.push_str(&format!("- {}\n", item.summary));
111        }
112        output.push('\n');
113    }
114
115    if !packet.active_context.items.is_empty() {
116        output.push_str("Active context:\n");
117        for item in &packet.active_context.items {
118            output.push_str(&format!("- {}\n", item.summary));
119        }
120        output.push('\n');
121    }
122
123    if !packet.recommended_notes.is_empty() {
124        output.push_str("Recommended notes:\n");
125        for note in &packet.recommended_notes {
126            output.push_str(&format!("- {} ({})\n", note.title, note.path));
127        }
128        output.push('\n');
129    }
130
131    if !packet.maintenance_hints.is_empty() {
132        output.push_str("Maintenance:\n");
133        for hint in &packet.maintenance_hints {
134            output.push_str(&format!("- {}\n", hint));
135        }
136    }
137
138    output
139}
140
141fn render_section(output: &mut String, heading: &str, items: &[WakeupMemoryItem]) {
142    output.push_str(&format!("## {}\n\n", heading));
143    if items.is_empty() {
144        output.push_str("- none\n\n");
145        return;
146    }
147    for item in items {
148        output.push_str(&format!("- {} — {}\n", item.title, item.summary));
149    }
150    output.push('\n');
151}
152
153fn render_notes(output: &mut String, notes: &[WakeupRecommendedNote]) {
154    output.push_str("## Recommended notes\n\n");
155    if notes.is_empty() {
156        output.push_str("- none\n\n");
157        return;
158    }
159    for note in notes {
160        output.push_str(&format!(
161            "- {} [{}] — {}\n",
162            note.title, note.path, note.why_relevant
163        ));
164    }
165    output.push('\n');
166}
167
168fn profile_label(profile: WakeupProfile) -> &'static str {
169    match profile {
170        WakeupProfile::Developer => "developer",
171        WakeupProfile::Project => "project",
172    }
173}
174
175fn target_label(target: TargetTool) -> &'static str {
176    match target {
177        TargetTool::Claude => "claude",
178        TargetTool::Codex => "codex",
179        TargetTool::Opencode => "opencode",
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::{render_markdown, render_prompt};
186    use crate::domain::{
187        ConfidenceTier, TargetTool, WakeupIdentity, WakeupPacket, WakeupPolicy, WakeupProfile,
188        WakeupProvenance, WakeupQuery, WakeupRecommendedNote, WakeupSection,
189    };
190
191    fn make_packet(target: TargetTool) -> WakeupPacket {
192        WakeupPacket {
193            version: "wakeup.v1".to_string(),
194            generated_at: "unix:1".to_string(),
195            target,
196            profile: WakeupProfile::Project,
197            query: WakeupQuery {
198                task: "demo task".to_string(),
199                cwd: "/tmp/repo".to_string(),
200                files: vec!["src/app.rs".to_string()],
201            },
202            identity: WakeupIdentity {
203                project_id: Some("spool".to_string()),
204                project_name: Some("spool".to_string()),
205                repo_paths: vec!["/tmp/repo".to_string()],
206                modules: Vec::new(),
207                scenes: Vec::new(),
208                active_profile: "project".to_string(),
209                developer_roots: Vec::new(),
210            },
211            knowledge_index: None,
212            working_style: WakeupSection::default(),
213            active_context: WakeupSection::default(),
214            priorities: Vec::new(),
215            constraints: Vec::new(),
216            decisions: Vec::new(),
217            incidents: Vec::new(),
218            recommended_notes: vec![WakeupRecommendedNote {
219                path: "10-Projects/demo.md".to_string(),
220                title: "Demo".to_string(),
221                memory_type: Some("project".to_string()),
222                why_relevant: "matched project token".to_string(),
223                score: 10,
224                confidence: ConfidenceTier::Medium,
225            }],
226            maintenance_hints: Vec::new(),
227            provenance: WakeupProvenance::default(),
228            policy: WakeupPolicy {
229                max_sensitivity_included: Some("internal".to_string()),
230                redactions_applied: false,
231                suppressed_note_count: 0,
232                policy_mode: "conservative_default".to_string(),
233            },
234        }
235    }
236
237    #[test]
238    fn prompt_renderer_should_include_target_specific_intro() {
239        let rendered = render_prompt(&make_packet(TargetTool::Codex));
240        assert!(rendered.contains("给 Codex 使用的 wake-up packet"));
241    }
242
243    #[test]
244    fn markdown_renderer_should_include_policy_block() {
245        let rendered = render_markdown(&make_packet(TargetTool::Claude));
246        assert!(rendered.contains("## Policy"));
247        assert!(rendered.contains("max_sensitivity_included: internal"));
248    }
249}