Skip to main content

scone/
hook.rs

1//! `scone hook <event>`: Claude Code hook handlers, all local, all
2//! fail-open. The predecessor's plugin runs Node scripts with 3-second
3//! network caps; ours is the engine binary itself on millisecond budgets
4//! (design: docs/superpowers/specs/2026-08-28-team-brain-concept.md).
5//!
6//! Contract: stdout becomes injected context (SessionStart /
7//! UserPromptSubmit); any internal failure exits 0 with empty stdout so a
8//! broken store never stalls a session. Errors go to stderr.
9
10use scone_core::{Engine, RecallOpts, auth};
11
12pub fn session_start(engine: &mut Engine, space_name: &str) -> String {
13    let mut run = || -> Result<String, String> {
14        let space = auth::resolve(engine, space_name, true).map_err(|e| e.to_string())?;
15        let profile = engine.profile(&space, 6).map_err(|e| e.to_string())?;
16        let mut out = String::new();
17        if !profile.static_facts.is_empty() {
18            out.push_str("Persistent memory for this project:\n");
19            for f in &profile.static_facts {
20                out.push_str(&format!("- {} {} {}\n", f.subject, f.predicate, f.object));
21            }
22        }
23        if !profile.dynamic.is_empty() {
24            out.push_str("Recent activity:\n");
25            for d in profile.dynamic.iter().take(3) {
26                out.push_str(&format!("- {}\n", d.replace('\n', " ")));
27            }
28        }
29        Ok(out)
30    };
31    run().unwrap_or_else(|e| {
32        eprintln!("scone hook session-start: {e}");
33        String::new()
34    })
35}
36
37pub fn user_prompt(engine: &mut Engine, space_name: &str, stdin: &str) -> String {
38    let mut run = || -> Result<String, String> {
39        let value: serde_json::Value = serde_json::from_str(stdin).map_err(|e| e.to_string())?;
40        let prompt = value["prompt"].as_str().ok_or("no prompt field")?;
41        if prompt.trim().len() < 8 {
42            return Ok(String::new());
43        }
44        let space = auth::resolve(engine, space_name, true).map_err(|e| e.to_string())?;
45        let pack = engine
46            .recall(
47                &space,
48                prompt,
49                &RecallOpts {
50                    limit: 3,
51                    ..Default::default()
52                },
53            )
54            .map_err(|e| e.to_string())?;
55        if pack.facts.is_empty() && pack.items.is_empty() {
56            return Ok(String::new());
57        }
58        let mut out = String::from("Relevant memory:\n");
59        for f in &pack.facts {
60            out.push_str(&format!("- {} {} {}\n", f.subject, f.predicate, f.object));
61        }
62        for item in &pack.items {
63            let text: String = item.text.chars().take(300).collect();
64            out.push_str(&format!("- [{}] {}\n", item.day(), text.replace('\n', " ")));
65        }
66        Ok(out)
67    };
68    run().unwrap_or_else(|e| {
69        eprintln!("scone hook user-prompt: {e}");
70        String::new()
71    })
72}
73
74pub fn session_end(engine: &mut Engine, space_name: &str, stdin: &str) {
75    let mut run = || -> Result<(), String> {
76        let value: serde_json::Value = serde_json::from_str(stdin).map_err(|e| e.to_string())?;
77        let path = value["transcript_path"]
78            .as_str()
79            .ok_or("no transcript_path")?;
80        let raw = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
81        let mut lines = Vec::new();
82        for line in raw.lines() {
83            let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
84                continue;
85            };
86            let role = entry["message"]["role"].as_str().unwrap_or_default();
87            if role != "user" && role != "assistant" {
88                continue;
89            }
90            let Some(parts) = entry["message"]["content"].as_array() else {
91                continue;
92            };
93            for part in parts {
94                if let Some(text) = part["text"].as_str()
95                    && !text.trim().is_empty()
96                {
97                    lines.push(format!("{role}: {text}"));
98                }
99            }
100        }
101        if lines.is_empty() {
102            return Ok(());
103        }
104        let space = auth::resolve(engine, space_name, true).map_err(|e| e.to_string())?;
105        let (episode_id, _fresh) = engine
106            .import_episode(&space, "conversation", &lines.join("\n"), None, None)
107            .map_err(|e| e.to_string())?;
108        engine
109            .tag_episode(&space, episode_id, &["claude-code"])
110            .map_err(|e| e.to_string())?;
111        if engine.has_llm() {
112            let _ = engine.distill(&space, 25);
113        }
114        Ok(())
115    };
116    if let Err(e) = run() {
117        eprintln!("scone hook session-end: {e}");
118    }
119}