Skip to main content

spool/hook_runtime/
session_start.rs

1//! `spool hook session-start` — emit a wakeup packet at session start.
2//!
3//! ## Output contract
4//! - stdout: a textual prompt block, prefixed by an HTML comment
5//!   marker so consumers can locate / strip the spool region.
6//! - exit 0: always (failure is handled by [`run_silent`] in `mod.rs`).
7//!
8//! ## Behavior modes
9//! - **Trellis-aware degraded mode** (D6): when
10//!   `<cwd>/.trellis/.developer` exists we emit a single-line note
11//!   pointing to `spool memory wakeup` instead of the full packet.
12//!   This avoids two assistants both pushing their own wakeup blob into
13//!   the system prompt.
14//! - **Empty wakeup**: when the project has no wakeup-ready memories,
15//!   we still emit a minimal block so Claude Code knows the hook is
16//!   wired (vs silently doing nothing — confusing during onboarding).
17//!
18//! ## What we deliberately do NOT do here
19//! - Network calls or sampling. SessionStart must finish in <500ms.
20//! - Mutating ledger state. SessionStart is read-only.
21
22use 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/// Arguments for the `session-start` hook.
31#[derive(Debug, Clone)]
32pub struct SessionStartArgs {
33    pub config_path: PathBuf,
34    /// Project root the user is currently working in. Defaults to the
35    /// process's `current_dir` when absent.
36    pub cwd: Option<PathBuf>,
37    /// Optional task hint forwarded to the wakeup gateway. SessionStart
38    /// usually has no concrete task — we route by `cwd` alone — but we
39    /// keep the slot to mirror the CLI surface.
40    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
121/// Maximum characters for the session-start memory injection.
122/// ~2000 chars ≈ 500 tokens — keeps the injection lightweight.
123const MAX_INJECTION_CHARS: usize = 2000;
124const MAX_SUMMARY_CHARS: usize = 120;
125
126/// Render a compact, spool-branded memory injection from the wakeup packet.
127/// Applies token budget: prioritizes constraints > decisions > preferences > incidents.
128/// Truncates individual summaries and drops lower-priority items when over budget.
129fn 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 &sections {
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}