Skip to main content

ski/
session_start.rs

1//! `ski session-start` — the `SessionStart` path. Two jobs, both best-effort:
2//!
3//! 1. **Incremental reindex** so a session always sees newly added or edited
4//!    skills (reuses unchanged embeddings; only the delta is re-embedded).
5//! 2. **Re-arm on compaction**: when the session restarts from a compacted
6//!    summary (`source == "compact"`), forget what was loaded so the relevant
7//!    skills inject again into the fresh context.
8//!
9//! **Fail open**: any error is swallowed; never blocks session start.
10
11use crate::config::Config;
12use crate::hook::Host;
13use crate::index::{self, Index};
14use crate::session::Session;
15use crate::{embed, paths, skill};
16use serde::Deserialize;
17use std::io::Read;
18
19/// `SessionStart` payload: which conversation, and why it started
20/// (`startup` | `resume` | `compact`).
21#[derive(Debug, Default, Deserialize)]
22struct RawEvent {
23    #[serde(default)]
24    session_id: String,
25    #[serde(default)]
26    source: String,
27}
28
29/// Run for `host`. `host` scopes the reindex to that host's skill library and
30/// its own index file (see [`crate::config::Config::for_host`]); the session
31/// re-arm is host-agnostic (session ids are unique across hosts).
32pub fn run(host: Host) -> anyhow::Result<()> {
33    // fail open: never surface an error to the harness (only trace under SKI_DEBUG).
34    if let Err(e) = session_start(host) {
35        crate::trace::debug("session-start failed", &e);
36    }
37    Ok(())
38}
39
40fn session_start(host: Host) -> anyhow::Result<()> {
41    let mut buf = String::new();
42    std::io::stdin().read_to_string(&mut buf)?;
43    let ev: RawEvent = serde_json::from_str(&buf).unwrap_or_default();
44
45    reindex(host);
46
47    if should_rearm(&ev.source) && !ev.session_id.is_empty() {
48        let path = paths::session_path(&ev.session_id);
49        let mut session = Session::load(&path);
50        session.clear();
51        let _ = session.save(&path);
52    }
53    Ok(())
54}
55
56/// Incrementally refresh the persisted index. Best-effort: any failure (no
57/// skills, embedder build, IO) leaves the previous index untouched (traced
58/// under `SKI_DEBUG` — a reindex that silently never lands is otherwise
59/// indistinguishable from "no skills changed").
60fn reindex(host: Host) {
61    let (cfg, _file) = Config::load(host);
62    let skills = match skill::discover(&cfg.roots) {
63        Ok(s) => s,
64        Err(e) => return crate::trace::debug("session-start: skill discovery failed", &e),
65    };
66    let embedder = match embed::build(&cfg.model) {
67        Ok(e) => e,
68        Err(e) => return crate::trace::debug("session-start: embedder build failed", &e),
69    };
70    let index_path = paths::index_path(host);
71    let prev = Index::load(&index_path).ok().flatten();
72    match index::build(&skills, embedder.as_ref(), prev.as_ref()) {
73        Ok(idx) => {
74            if let Err(e) = idx.save(&index_path) {
75                crate::trace::debug("session-start: saving reindexed index failed", &e);
76            }
77        }
78        Err(e) => crate::trace::debug("session-start: index build failed", &e),
79    }
80}
81
82/// Only a compaction re-arms the session; `startup`/`resume` keep their ledger.
83fn should_rearm(source: &str) -> bool {
84    source.eq_ignore_ascii_case("compact")
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::session::Source;
91
92    #[test]
93    fn only_compact_rearms() {
94        assert!(should_rearm("compact"));
95        assert!(!should_rearm("startup"));
96        assert!(!should_rearm("resume"));
97        assert!(!should_rearm(""));
98    }
99
100    #[test]
101    fn clear_on_compact_empties_the_ledger() {
102        let mut s = Session::default();
103        s.mark("pdf", Source::Ski);
104        s.mark("xlsx", Source::Model);
105        if should_rearm("compact") {
106            s.clear();
107        }
108        assert!(s.loaded.is_empty());
109    }
110}