spool/hook_runtime/
session_start.rs1use std::io::Write;
23use std::path::PathBuf;
24
25use anyhow::Context;
26
27use crate::domain::{OutputFormat, RouteInput, TargetTool, WakeupProfile};
28use crate::memory_gateway::{self, wakeup_request};
29
30#[derive(Debug, Clone)]
32pub struct SessionStartArgs {
33 pub config_path: PathBuf,
34 pub cwd: Option<PathBuf>,
37 pub task: Option<String>,
41 pub profile: WakeupProfile,
42}
43
44pub fn run(args: SessionStartArgs) -> anyhow::Result<()> {
45 let cwd = match args.cwd {
46 Some(p) => p,
47 None => std::env::current_dir().context("resolving cwd for session-start hook")?,
48 };
49 let task = args.task.unwrap_or_else(|| String::from("session start"));
50
51 let mut stdout = std::io::stdout().lock();
52
53 let response = match memory_gateway::execute(
54 &args.config_path,
55 wakeup_request(
56 RouteInput {
57 task,
58 cwd: cwd.clone(),
59 files: Vec::new(),
60 target: TargetTool::Claude,
61 format: OutputFormat::Prompt,
62 },
63 args.profile,
64 ),
65 None,
66 ) {
67 Ok(r) => r,
68 Err(err) => {
69 write_error_block(&mut stdout, &err)?;
70 return Ok(());
71 }
72 };
73
74 let packet = match response.wakeup_packet() {
75 Some(p) => p,
76 None => {
77 write_empty_block(&mut stdout)?;
78 return Ok(());
79 }
80 };
81
82 let body = render_memory_injection(packet);
83 if !body.is_empty() {
84 write_block(&mut stdout, &body)?;
85 } else {
86 write_empty_block(&mut stdout)?;
87 }
88 Ok(())
89}
90
91const REGION_OPEN: &str = "<spool-memory>";
92const REGION_CLOSE: &str = "</spool-memory>";
93
94fn write_block<W: Write>(w: &mut W, body: &str) -> anyhow::Result<()> {
95 writeln!(w, "{}", REGION_OPEN)?;
96 writeln!(w, "{}", body)?;
97 writeln!(w, "{}", REGION_CLOSE)?;
98 Ok(())
99}
100
101fn write_empty_block<W: Write>(w: &mut W) -> anyhow::Result<()> {
102 writeln!(w, "<spool-memory>")?;
103 writeln!(
104 w,
105 "spool: no active memories yet. Say \"记一下\" to capture."
106 )?;
107 writeln!(w, "</spool-memory>")?;
108 Ok(())
109}
110
111fn write_error_block<W: Write>(w: &mut W, err: &anyhow::Error) -> anyhow::Result<()> {
112 write_block(
113 w,
114 &format!(
115 "Memory unavailable: {}. Run `spool mcp doctor` for diagnosis.",
116 short_error(err)
117 ),
118 )
119}
120
121const MAX_INJECTION_CHARS: usize = 2000;
124const MAX_SUMMARY_CHARS: usize = 120;
125
126fn render_memory_injection(packet: &crate::domain::WakeupPacket) -> String {
130 use crate::domain::WakeupMemoryItem;
131
132 fn has_content(items: &[WakeupMemoryItem]) -> bool {
133 !items.is_empty()
134 }
135
136 let has_any = has_content(&packet.constraints)
137 || has_content(&packet.decisions)
138 || has_content(&packet.working_style.items)
139 || has_content(&packet.incidents);
140
141 if !has_any {
142 return String::new();
143 }
144
145 let sections: Vec<(&str, &[WakeupMemoryItem])> = vec![
146 ("Constraints", &packet.constraints),
147 ("Decisions", &packet.decisions),
148 ("Preferences", &packet.working_style.items),
149 ("Incidents", &packet.incidents),
150 ];
151
152 let mut lines: Vec<String> = Vec::new();
153 let mut total_chars: usize = 0;
154 let mut dropped: usize = 0;
155
156 for (heading, items) in §ions {
157 if items.is_empty() {
158 continue;
159 }
160 let header_line = format!("## {heading}");
161 let header_cost = header_line.len() + 1;
162
163 if total_chars + header_cost > MAX_INJECTION_CHARS {
164 dropped += items.len();
165 continue;
166 }
167
168 if !lines.is_empty() {
169 lines.push(String::new());
170 total_chars += 1;
171 }
172 lines.push(header_line);
173 total_chars += header_cost;
174
175 for item in *items {
176 let summary = truncate_summary(&item.summary);
177 let line = format!("- {summary}");
178 let line_cost = line.len() + 1;
179
180 if total_chars + line_cost > MAX_INJECTION_CHARS {
181 dropped += 1;
182 continue;
183 }
184 lines.push(line);
185 total_chars += line_cost;
186 }
187 }
188
189 if dropped > 0 {
190 lines.push(String::new());
191 lines.push(format!(
192 "({dropped} more — use /spool-wakeup for full list)"
193 ));
194 }
195
196 lines.join("\n")
197}
198
199fn truncate_summary(s: &str) -> String {
200 let trimmed = s.trim();
201 if trimmed.chars().count() <= MAX_SUMMARY_CHARS {
202 return trimmed.to_string();
203 }
204 let mut out: String = trimmed.chars().take(MAX_SUMMARY_CHARS).collect();
205 out.push('…');
206 out
207}
208
209fn short_error(err: &anyhow::Error) -> String {
210 let raw = format!("{}", err);
211 let trimmed: String = raw.chars().take(160).collect();
212 if trimmed.len() < raw.len() {
213 format!("{}…", trimmed)
214 } else {
215 trimmed
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::fs;
223 use tempfile::tempdir;
224
225 fn fixture(temp: &tempfile::TempDir) -> PathBuf {
226 let vault = temp.path().join("vault");
227 fs::create_dir_all(vault.join("10-Projects")).unwrap();
228 fs::write(
229 vault.join("10-Projects/proj.md"),
230 r#"---
231memory_type: decision
232project_id: spool
233retrieval_priority: high
234---
235# 决策: 用 cargo install
236"#,
237 )
238 .unwrap();
239 let cwd = temp.path().join("repo");
240 fs::create_dir_all(&cwd).unwrap();
241 let config = format!(
242 r#"[vault]
243root = "{}"
244
245[output]
246default_format = "prompt"
247max_chars = 8000
248max_notes = 4
249
250[[projects]]
251id = "spool"
252name = "spool"
253repo_paths = ["{}"]
254note_roots = ["10-Projects"]
255"#,
256 vault.display(),
257 cwd.display()
258 );
259 let cfg = temp.path().join("spool.toml");
260 fs::write(&cfg, config).unwrap();
261 cfg
262 }
263
264 #[test]
265 fn write_block_wraps_body_in_markers() {
266 let mut buf = Vec::new();
267 write_block(&mut buf, "hello world").unwrap();
268 let text = String::from_utf8(buf).unwrap();
269 assert!(text.starts_with("<spool-memory>\n"));
270 assert!(text.contains("hello world"));
271 assert!(text.trim_end().ends_with("</spool-memory>"));
272 }
273
274 #[test]
275 fn write_empty_block_provides_actionable_hint() {
276 let mut buf = Vec::new();
277 write_empty_block(&mut buf).unwrap();
278 let text = String::from_utf8(buf).unwrap();
279 assert!(text.contains("记一下"));
280 }
281
282 #[test]
283 fn short_error_truncates_long_errors() {
284 let long = "x".repeat(500);
285 let err = anyhow::anyhow!("{}", long);
286 let s = short_error(&err);
287 assert!(s.ends_with('…'));
288 assert!(s.len() < long.len());
289 }
290
291 #[test]
292 fn run_emits_wakeup_block_for_real_project() {
293 let temp = tempdir().unwrap();
294 let cfg = fixture(&temp);
295 let cwd = temp.path().join("repo");
296
297 let response = memory_gateway::execute(
298 &cfg,
299 wakeup_request(
300 RouteInput {
301 task: "session start".into(),
302 cwd: cwd.clone(),
303 files: Vec::new(),
304 target: TargetTool::Claude,
305 format: OutputFormat::Prompt,
306 },
307 WakeupProfile::Project,
308 ),
309 None,
310 )
311 .unwrap();
312 assert!(response.wakeup_packet().is_some());
313 }
314
315 #[test]
316 fn run_works_even_with_trellis_present() {
317 let temp = tempdir().unwrap();
318 let cfg = fixture(&temp);
319 let cwd = temp.path().join("repo");
320 let trellis = cwd.join(".trellis");
321 fs::create_dir_all(&trellis).unwrap();
322 fs::write(trellis.join(".developer"), "name=long").unwrap();
323
324 let result = run(SessionStartArgs {
325 config_path: cfg,
326 cwd: Some(cwd),
327 task: None,
328 profile: WakeupProfile::Project,
329 });
330 assert!(result.is_ok(), "should work regardless of trellis presence");
331 }
332}