Skip to main content

mj_controller/
sessionwiki.rs

1//! Publishing Mjolnir's own checkpointed sessions into the user's SessionWiki
2//! index, and the daemon-side job that keeps that index current.
3//!
4//! SessionWiki keeps one searchable index of AI coding sessions across every
5//! tool a user runs. Mjolnir links it as a library and registers
6//! [`MjolnirAdapter`] beside SessionWiki's built-in adapters, so a Mjolnir
7//! session is searchable next to a Claude Code or Codex one. The adapter is a
8//! "shared store" adapter: checkpoints are not one-file-per-session in a shape
9//! SessionWiki can parse, so the indexer enumerates sessions by key and asks
10//! this adapter to parse the ones whose checkpoint changed.
11
12mod harness_adapters;
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::Instant;
19
20use anyhow::{Context, Result};
21use chrono::{DateTime, Utc};
22
23use mj_client::daemon::{WikiHitBlock, WikiHitTranscript, WikiIndexState, WikiRow, WikiStatus};
24use mj_core::state::{SessionRecord, State};
25use sessionwiki::adapters::{Adapter, Discovered, Store};
26use sessionwiki::model::{Message, Role, Session};
27
28use crate::controller::Controller;
29use crate::controller::checkpoint::managed_checkpoint_archive_name;
30use harness_adapters::HarnessAdapter;
31
32/// The tool name every Mjolnir instance publishes under. One name means one
33/// search partition; reconciliation is scoped per instance instead (see
34/// [`Adapter::reconcile_scope`]).
35const TOOL: &str = "mjolnir";
36
37/// One checkpoint archive on disk, reduced to what indexing needs.
38struct ArchiveFile {
39    path: PathBuf,
40    frontier: u64,
41    /// Modification time in epoch seconds, SessionWiki's change token.
42    token: i64,
43}
44
45/// What indexing needs from controller state: which sessions exist, which of
46/// them are sub-agent children, and which are still running with their
47/// conversation in the daemon's own database rather than in a checkpoint.
48#[derive(Default)]
49struct Sessions {
50    records: BTreeMap<String, SessionRecord>,
51    subagent_ids: BTreeSet<String>,
52    /// Session id to change token, for sessions indexed from the projection.
53    live: BTreeMap<String, i64>,
54}
55
56impl Sessions {
57    fn of(state: &State) -> Self {
58        Self {
59            records: state.sessions.clone(),
60            subagent_ids: state.subagents.keys().cloned().collect(),
61            live: live_tokens(state),
62        }
63    }
64}
65
66/// The change token of every session whose transcript is still only in the
67/// daemon's database: its activity watermark in whole seconds.
68///
69/// A stopped session keeps being indexed from its checkpoint, which never
70/// changes again. Everything else is indexed from the projection, so a running
71/// session is findable before it has ever been closed.
72fn live_tokens(state: &State) -> BTreeMap<String, i64> {
73    let activity = match crate::database::load_transcribed_session_activity() {
74        Ok(activity) => activity,
75        Err(error) => {
76            tracing::warn!(%error, "could not read session activity for SessionWiki");
77            return BTreeMap::new();
78        }
79    };
80    state
81        .sessions
82        .iter()
83        .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
84        .filter_map(|(session_id, _)| {
85            let watermark = activity.get(session_id)?;
86            Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
87        })
88        .collect()
89}
90
91/// Mjolnir's sessions, as SessionWiki sees them.
92pub struct MjolnirAdapter {
93    sessions_dir: PathBuf,
94    sessions: std::sync::Mutex<Sessions>,
95    /// Re-read controller state when the indexer reaches this adapter.
96    reload: bool,
97}
98
99impl MjolnirAdapter {
100    /// A fixed view of the given state, which is what a caller with a state in
101    /// hand wants.
102    pub fn from_state(state: &State) -> Self {
103        Self {
104            sessions_dir: mj_core::config::sessions_dir(),
105            sessions: std::sync::Mutex::new(Sessions::of(state)),
106            reload: false,
107        }
108    }
109
110    /// The same, but re-reading controller state when the indexer reaches this
111    /// adapter.
112    ///
113    /// One sync pass walks every other tool's store first, which can take
114    /// minutes on a large corpus. Without the reload, sessions that closed
115    /// during that walk would be indexed with no record: no project, no start
116    /// time, and the title guessed from the first prompt. Their checkpoints do
117    /// not change afterwards, so nothing would ever correct them.
118    pub fn reloading(state: &State) -> Self {
119        Self {
120            reload: true,
121            ..Self::from_state(state)
122        }
123    }
124
125    fn reload(&self) {
126        if !self.reload {
127            return;
128        }
129        match Controller::load() {
130            Ok(controller) => {
131                *self
132                    .sessions
133                    .lock()
134                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
135                    Sessions::of(&controller.state)
136            }
137            Err(error) => {
138                tracing::warn!(%error, "could not refresh session records for SessionWiki")
139            }
140        }
141    }
142
143    /// The conversation of a stopped session, read from its newest checkpoint,
144    /// with the title the checkpoint recorded.
145    fn checkpointed_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
146        let (newest, _) = self.newest_archives();
147        let archive = newest
148            .get(session_id)
149            .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
150        let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
151            .with_context(|| format!("read checkpoint {}", archive.path.display()))?
152            .canonical_session()
153            .with_context(|| format!("read the transcript of session {session_id}"))?;
154        let messages = snapshot
155            .transcript
156            .iter()
157            .filter_map(|item| {
158                let (role, text) = match &item.body {
159                    mj_core::archive::CanonicalTranscriptBody::User { content } => (
160                        Role::User,
161                        mj_core::transcript::materialized_content_text(content),
162                    ),
163                    mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
164                        Role::Assistant,
165                        mj_core::transcript::materialized_chunks_text(chunks),
166                    ),
167                    mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => {
168                        (Role::Tool, tool_call_title(call))
169                    }
170                    _ => return None,
171                };
172                message(role, text, item.created_at_ms)
173            })
174            .collect();
175        Ok((messages, snapshot.session.session_title.clone()))
176    }
177
178    /// The conversation of a session that has not stopped, read from the
179    /// daemon's own projection. It is the same conversation the checkpoint
180    /// would hold, minus whatever has not happened yet.
181    fn projected_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
182        let projection = crate::database::load_materialized_session(session_id)
183            .with_context(|| format!("read the stored transcript of session {session_id}"))?
184            .with_context(|| format!("no stored transcript for session {session_id}"))?;
185        Ok((
186            projected_messages(&projection),
187            projection.session_title.clone(),
188        ))
189    }
190
191    /// The stable key for one session: its checkpoint directory and id. The
192    /// directory is per instance, which is what scopes reconciliation.
193    fn key_for(&self, session_id: &str) -> String {
194        format!("{}/{session_id}", self.sessions_dir.display())
195    }
196
197    /// The newest checkpoint of every session in the directory, by session id.
198    ///
199    /// `had_error` is true when the directory exists but could not be read in
200    /// full; the indexer then skips deletion reconciliation rather than
201    /// archiving every Mjolnir session off a partial listing.
202    fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
203        let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
204        let mut had_error = false;
205        let entries = match std::fs::read_dir(&self.sessions_dir) {
206            Ok(entries) => entries,
207            Err(error) => {
208                if self.sessions_dir.exists() {
209                    tracing::debug!(
210                        directory = %self.sessions_dir.display(),
211                        %error,
212                        "could not list the checkpoint directory for SessionWiki"
213                    );
214                    had_error = true;
215                }
216                return (newest, had_error);
217            }
218        };
219        for entry in entries {
220            let Ok(entry) = entry else {
221                had_error = true;
222                continue;
223            };
224            let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
225            else {
226                continue;
227            };
228            let token = entry
229                .metadata()
230                .ok()
231                .and_then(|metadata| metadata.modified().ok())
232                .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
233                .map(|age| age.as_secs() as i64)
234                .unwrap_or(0);
235            let candidate = ArchiveFile {
236                path: entry.path(),
237                frontier,
238                token,
239            };
240            match newest.get(&session_id) {
241                Some(existing) if existing.frontier >= candidate.frontier => {}
242                _ => {
243                    newest.insert(session_id, candidate);
244                }
245            }
246        }
247        (newest, had_error)
248    }
249}
250
251/// The session a checkpoint file name belongs to, with its generation.
252///
253/// Managed checkpoints carry a frontier and a nonce; an imported archive is
254/// named for its session alone and counts as generation zero.
255fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
256    if let Some(parsed) = managed_checkpoint_archive_name(name) {
257        return Some((parsed.session_id, parsed.frontier));
258    }
259    let stem = name
260        .to_str()
261        .and_then(|name| name.strip_suffix(".hel.zip"))?;
262    mj_core::config::validate_id("session", stem)
263        .is_ok()
264        .then(|| (stem.to_owned(), 0))
265}
266
267/// A running session's conversation, as SessionWiki stores it.
268fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
269    projection
270        .transcript
271        .iter()
272        .filter_map(|item| {
273            let (role, text) = match &item.body {
274                mj_core::state::TranscriptBody::User { content } => (
275                    Role::User,
276                    mj_core::transcript::materialized_content_text(content),
277                ),
278                mj_core::state::TranscriptBody::Agent { chunks, .. } => (
279                    Role::Assistant,
280                    mj_core::transcript::materialized_chunks_text(chunks),
281                ),
282                mj_core::state::TranscriptBody::Tool { call, .. } => {
283                    (Role::Tool, tool_call_title(call))
284                }
285                _ => return None,
286            };
287            message(role, text, item.created_at_ms)
288        })
289        .collect()
290}
291
292/// The tool's own title, which is what the transcript showed the user.
293/// Arguments and output are not worth indexing.
294fn tool_call_title(call: &serde_json::Value) -> String {
295    call.get("title")
296        .and_then(serde_json::Value::as_str)
297        .unwrap_or_default()
298        .to_owned()
299}
300
301/// One indexed message, or nothing when the item carried no text.
302fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
303    let text = text.trim().to_owned();
304    (!text.is_empty()).then(|| Message {
305        role,
306        text,
307        ts: DateTime::from_timestamp_millis(created_at_ms),
308    })
309}
310
311fn parse_time(value: &str) -> Option<DateTime<Utc>> {
312    DateTime::parse_from_rfc3339(value)
313        .ok()
314        .map(|time| time.with_timezone(&Utc))
315}
316
317impl Adapter for MjolnirAdapter {
318    fn name(&self) -> &'static str {
319        TOOL
320    }
321
322    fn root(&self) -> Option<PathBuf> {
323        Some(self.sessions_dir.clone())
324    }
325
326    /// Unused: this is a shared-store adapter, so the indexer enumerates
327    /// sessions through [`Adapter::store`] instead of walking files.
328    fn discover(&self) -> Discovered {
329        Discovered {
330            files: Vec::new(),
331            had_error: false,
332        }
333    }
334
335    fn parse(&self, _path: &Path) -> Result<Session> {
336        anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
337    }
338
339    fn store(&self) -> Option<Store> {
340        self.reload();
341        let (newest, had_error) = self.newest_archives();
342        let mut files = Vec::with_capacity(newest.len());
343        let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
344        for (session_id, archive) in newest {
345            tokens.insert(session_id, archive.token);
346            files.push(archive.path);
347        }
348        // A session that is still running is indexed from the projection, and
349        // its own token replaces any checkpoint token it has: the conversation
350        // has moved on since that checkpoint was written. Listing it also
351        // keeps reconciliation from archiving a running session.
352        let sessions = self
353            .sessions
354            .lock()
355            .unwrap_or_else(std::sync::PoisonError::into_inner);
356        let live = sessions.live.clone();
357        tokens.extend(live);
358        // A rename changes the record and not the conversation, so the
359        // record's own last update is part of the change token. Without it a
360        // renamed session would keep its old title in the index for as long as
361        // its transcript stood still.
362        for (session_id, token) in tokens.iter_mut() {
363            let updated = sessions
364                .records
365                .get(session_id)
366                .and_then(|record| parse_time(&record.updated_at))
367                .map(|updated| updated.timestamp());
368            if let Some(updated) = updated {
369                *token = (*token).max(updated);
370            }
371        }
372        let keys = tokens
373            .into_iter()
374            .map(|(session_id, token)| (self.key_for(&session_id), token))
375            .collect();
376        Some(Store {
377            keys,
378            files,
379            had_error,
380        })
381    }
382
383    /// Every Mjolnir instance publishes under one tool name, so this instance
384    /// speaks only for keys under its own checkpoint directory. Without the
385    /// scope, two instances would archive each other's rows on every sync.
386    fn reconcile_scope(&self) -> Option<String> {
387        Some(format!("{}/", self.sessions_dir.display()))
388    }
389
390    fn parse_key(&self, key: &str) -> Result<Session> {
391        let session_id = key.rsplit('/').next().unwrap_or_default();
392        anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
393        let sessions = self
394            .sessions
395            .lock()
396            .unwrap_or_else(std::sync::PoisonError::into_inner);
397        let (messages, snapshot_title) = if sessions.live.contains_key(session_id) {
398            self.projected_transcript(session_id)?
399        } else {
400            self.checkpointed_transcript(session_id)?
401        };
402        let record = sessions.records.get(session_id);
403
404        let title = record
405            .and_then(|record| record.session_title_override.clone())
406            .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
407            .or_else(|| snapshot_title.clone())
408            .unwrap_or_else(|| {
409                messages
410                    .iter()
411                    .find(|message| message.role == Role::User)
412                    .map(|message| message.text.chars().take(80).collect())
413                    .unwrap_or_default()
414            });
415
416        Ok(Session {
417            id: session_id.to_owned(),
418            tool: TOOL,
419            path: PathBuf::from(key),
420            project: record
421                .and_then(|record| record.project_directory.as_ref())
422                .map(|directory| directory.display().to_string())
423                .unwrap_or_default(),
424            started: record.and_then(|record| parse_time(&record.created_at)),
425            ended: record.and_then(|record| parse_time(&record.updated_at)),
426            title,
427            subagent: sessions.subagent_ids.contains(session_id),
428            messages,
429            touched: Vec::new(),
430            edits: Vec::new(),
431        })
432    }
433}
434
435/// The daemon's SessionWiki sync job.
436///
437/// Triggers coalesce: a request while a sync is running marks a rerun instead
438/// of queueing a second one, so a burst of closing sessions costs one extra
439/// pass. Syncs are single-flight because SessionWiki holds a write transaction
440/// per adapter batch, and two writers only produce a busy error.
441pub struct WikiIndexer {
442    inner: Arc<Indexer>,
443}
444
445#[derive(Default)]
446struct Indexer {
447    /// Held for the whole of one run: this is what makes syncs single-flight.
448    running: tokio::sync::Mutex<()>,
449    notify: tokio::sync::Notify,
450    /// A trigger arrived; the worker has not consumed it yet.
451    requested: AtomicBool,
452    /// At least one waiting trigger asked for a full sync.
453    full_requested: AtomicBool,
454    /// A sync pass is running now. A surface shows this as "topping up", so a
455    /// user knows more results may arrive.
456    in_flight: AtomicBool,
457    last_success: std::sync::Mutex<Option<Success>>,
458}
459
460#[derive(Clone, Copy)]
461struct Success {
462    at: Instant,
463    epoch_seconds: i64,
464}
465
466impl WikiIndexer {
467    /// Start the background sync worker. Without a Tokio runtime (some tests
468    /// build a runtime state without one) the indexer stays inert.
469    pub fn spawn() -> Self {
470        let inner = Arc::new(Indexer::default());
471        if let Ok(handle) = tokio::runtime::Handle::try_current() {
472            let worker = Arc::clone(&inner);
473            handle.spawn(async move { worker.run().await });
474        }
475        Self { inner }
476    }
477
478    /// Ask for a sync. Returns immediately; the work happens in the background.
479    pub fn request_sync(&self, full: bool) {
480        if full {
481            self.inner.full_requested.store(true, Ordering::Release);
482        }
483        self.inner.requested.store(true, Ordering::Release);
484        self.inner.notify.notify_one();
485    }
486
487    /// Run a sync and wait for it, joining a sync already in flight.
488    pub async fn sync_now(&self, full: bool) -> Result<()> {
489        self.inner.sync(full).await
490    }
491
492    /// The state of the index and whether a sync is running, for the surfaces
493    /// that say so while the first build is under way.
494    pub fn status(&self) -> WikiStatus {
495        WikiStatus {
496            state: index_state(),
497            topping_up: self.inner.in_flight.load(Ordering::Acquire)
498                || self.inner.requested.load(Ordering::Acquire),
499        }
500    }
501
502    /// When the last sync succeeded, for callers that trigger on staleness.
503    pub fn last_success(&self) -> Option<Instant> {
504        self.inner
505            .last_success
506            .lock()
507            .unwrap_or_else(std::sync::PoisonError::into_inner)
508            .map(|success| success.at)
509    }
510}
511
512impl Indexer {
513    async fn run(self: Arc<Self>) {
514        loop {
515            self.notify.notified().await;
516            while self.requested.swap(false, Ordering::AcqRel) {
517                let full = self.full_requested.swap(false, Ordering::AcqRel);
518                if let Err(error) = self.sync(full).await {
519                    self.report(&error);
520                    // A failure waits for the next trigger rather than
521                    // retrying straight away: a busy index stays busy for as
522                    // long as the other writer holds it, and a spin would only
523                    // add to the contention.
524                    break;
525                }
526            }
527        }
528    }
529
530    /// Log a failed sync at the level its cause deserves. A busy index is an
531    /// expected collision with another writer, not a fault: mark a rerun and
532    /// say so only in debug output.
533    fn report(&self, error: &anyhow::Error) {
534        if is_busy(error) {
535            self.requested.store(true, Ordering::Release);
536            tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
537        } else {
538            tracing::warn!(%error, "could not sync sessions into SessionWiki");
539        }
540    }
541
542    async fn sync(&self, full: bool) -> Result<()> {
543        let _guard = self.running.lock().await;
544        let since = if full {
545            None
546        } else {
547            self.last_success
548                .lock()
549                .unwrap_or_else(std::sync::PoisonError::into_inner)
550                // A minute of overlap covers checkpoints written while the
551                // previous run was reading the directory.
552                .map(|success| success.epoch_seconds - 60)
553        };
554        let started = Instant::now();
555        self.in_flight.store(true, Ordering::Release);
556        let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
557        self.in_flight.store(false, Ordering::Release);
558        let ran = ran.context("run the SessionWiki sync")??;
559        if ran {
560            *self
561                .last_success
562                .lock()
563                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
564                at: started,
565                epoch_seconds: Utc::now().timestamp(),
566            });
567        }
568        Ok(())
569    }
570}
571
572/// One synchronous sync pass. Returns false when this process must not touch
573/// the index, so a refused run never records a success it did not have.
574fn sync_blocking(since: Option<i64>) -> Result<bool> {
575    if !index_is_writable() {
576        return Ok(false);
577    }
578    let controller =
579        Controller::load().context("load controller state for the SessionWiki sync")?;
580    // Mjolnir's own sessions go first: a cold index walks every other tool's
581    // store for many minutes, and a just-closed session should not wait on it.
582    let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
583        vec![Box::new(MjolnirAdapter::reloading(&controller.state))];
584    adapters.extend(native_adapters(&controller.config));
585    let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
586    sessionwiki::index::sync_with(&mut connection, &adapters, since)
587        .context("sync the SessionWiki index")?;
588    if since.is_none() {
589        // A full pass has walked every store, so the index is complete enough
590        // for a search to be trusted. The marker is what a later daemon reads
591        // instead of walking the corpus again to find out.
592        record_first_build();
593    }
594    Ok(true)
595}
596
597/// The non-Mjolnir adapters this install indexes.
598///
599/// Mjolnir's configured harness profiles decide which harness homes are
600/// indexed, not the stock `~/.codex` and `~/.claude` locations. A user who
601/// runs several profile homes expects every session Mjolnir can start to be
602/// searchable, and a home no profile names is not Mjolnir's to walk. So the
603/// stock Codex and Claude adapters are dropped and one adapter per enabled
604/// profile home takes their place; every other built-in adapter is kept as is.
605///
606/// Kimi Code, Grok Build and Muse have no SessionWiki adapter at all, so
607/// Mjolnir supplies one per enabled profile home of its own (see
608/// [`harness_adapters`]). Without them those sessions would never appear in
609/// the Resume dialog's search.
610///
611/// Each per-home adapter reports a reconcile scope covering only its own root,
612/// so a sync of one install never archives the rows of another.
613fn native_adapters(config: &mj_core::config::Config) -> Vec<Box<dyn Adapter>> {
614    use mj_core::config::HarnessKind;
615
616    // Two profiles may share one home, and two harnesses may share one home
617    // path without sharing sessions, so the kind is part of the identity.
618    let mut seen: BTreeSet<(HarnessKind, &Path)> = BTreeSet::new();
619    let mut adapters: Vec<Box<dyn Adapter>> = Vec::new();
620    for (_, profile) in config.enabled_profiles() {
621        // A second adapter for the same home would only walk it twice.
622        if !seen.insert((profile.kind, profile.home.as_path())) {
623            continue;
624        }
625        let adapter: Box<dyn Adapter> = match profile.kind {
626            HarnessKind::Codex => {
627                Box::new(sessionwiki::adapters::Codex::in_home(profile.home.clone()))
628            }
629            HarnessKind::Claude => Box::new(sessionwiki::adapters::ClaudeCode::in_home(
630                profile.home.clone(),
631            )),
632            kind => match HarnessAdapter::in_home(kind, profile.home.clone()) {
633                Some(adapter) => Box::new(adapter),
634                None => continue,
635            },
636        };
637        adapters.push(adapter);
638    }
639    adapters.extend(
640        sessionwiki::adapters::all()
641            .into_iter()
642            .filter(|adapter| !matches!(adapter.name(), "codex" | "claude-code")),
643    );
644    adapters
645}
646
647// ---------------------------------------------------------------------------
648// Which index, and whether it may be touched
649// ---------------------------------------------------------------------------
650
651/// Whether this process may open the index at all.
652///
653/// Indexing is always on, so a process that never resolved where its index
654/// belongs must not reach for one: it would walk the user's real session
655/// stores and write the user's real index. Only Mjolnir's own startup resolves
656/// it (see `mj_core::config::apply_instance_flag`), so this refuses every unit
657/// test that builds a daemon runtime directly and every other embedder, unless
658/// it names an index of its own with `SESSIONWIKI_DATA`.
659fn index_is_isolated() -> bool {
660    static SAID: AtomicBool = AtomicBool::new(false);
661    if mj_core::config::session_index_is_resolved()
662        || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
663    {
664        return true;
665    }
666    if !SAID.swap(true, Ordering::AcqRel) {
667        tracing::debug!(
668            "this process did not resolve a session index location; SessionWiki is not used"
669        );
670    }
671    false
672}
673
674/// Whether the index on disk was written by a SessionWiki at another schema
675/// version.
676///
677/// SessionWiki's own `open` drops and rebuilds its whole cache when the file's
678/// `user_version` differs from the version it was built with, which on a large
679/// corpus costs tens of minutes. Mjolnir will not do that to a user who also
680/// runs the `sessionwiki` command: it reads the version without SessionWiki and
681/// stands aside.
682fn index_version_mismatch() -> bool {
683    static SAID: AtomicBool = AtomicBool::new(false);
684    let Ok(path) = sessionwiki::index::db_path() else {
685        return false;
686    };
687    if !path.exists() {
688        return false;
689    }
690    let version = rusqlite::Connection::open_with_flags(
691        &path,
692        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
693    )
694    .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
695    let version: i64 = match version {
696        Ok(version) => version,
697        Err(error) => {
698            tracing::debug!(%error, "could not read the SessionWiki index schema version");
699            return false;
700        }
701    };
702    // Zero is an index SessionWiki has not finished creating; it is not a
703    // different version.
704    let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
705    if mismatch && !SAID.swap(true, Ordering::AcqRel) {
706        tracing::warn!(
707            found = version,
708            expected = sessionwiki::index::SCHEMA_VERSION,
709            path = %path.display(),
710            "the SessionWiki index was written by another version;              Mjolnir will not open it, because opening it would rebuild it.              Install the matching sessionwiki command"
711        );
712    }
713    mismatch
714}
715
716fn index_is_writable() -> bool {
717    index_is_isolated() && !index_version_mismatch()
718}
719
720/// The file recording that one full sync has completed, holding the schema
721/// version it completed at.
722fn first_build_marker() -> PathBuf {
723    mj_core::config::data_dir().join("sessionwiki-built")
724}
725
726fn record_first_build() {
727    let path = first_build_marker();
728    let version = sessionwiki::index::SCHEMA_VERSION.to_string();
729    if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
730        return;
731    }
732    if let Err(error) = std::fs::write(&path, &version) {
733        tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
734    }
735}
736
737/// Whether this index has completed a full build at this schema version.
738fn first_build_is_done() -> bool {
739    std::fs::read_to_string(first_build_marker())
740        .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
741        && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
742}
743
744/// What a surface should say about this index right now.
745pub fn index_state() -> WikiIndexState {
746    if !index_is_isolated() {
747        return WikiIndexState::Indexing;
748    }
749    if index_version_mismatch() {
750        return WikiIndexState::VersionMismatch;
751    }
752    if first_build_is_done() {
753        WikiIndexState::Ready
754    } else {
755        WikiIndexState::Indexing
756    }
757}
758
759/// Whether a failure is SQLite reporting another writer, which a later trigger
760/// simply retries.
761fn is_busy(error: &anyhow::Error) -> bool {
762    error.chain().any(|cause| {
763        matches!(
764            cause.downcast_ref::<rusqlite::Error>(),
765            Some(rusqlite::Error::SqliteFailure(failure, _))
766                if matches!(
767                    failure.code,
768                    rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
769                )
770        )
771    })
772}
773
774// ---------------------------------------------------------------------------
775// Queries and restore
776// ---------------------------------------------------------------------------
777
778/// The largest page a caller may ask a wiki query for.
779pub const MAX_WIKI_LIMIT: usize = 200;
780/// The page size a caller that names none gets.
781pub const DEFAULT_WIKI_LIMIT: usize = 50;
782/// SessionWiki's full-text index needs three characters; shorter queries fall
783/// back to a substring scan.
784const MIN_FULLTEXT_QUERY: usize = 3;
785/// How stale the index may be before a query triggers a background sync.
786pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
787
788/// Whether a query should trigger a bounded background sync before it answers.
789pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
790    last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
791}
792
793/// One page of the index, newest first or best match first.
794///
795/// `live` is the set of session ids this daemon still holds, which is what
796/// decides whether a Mjolnir row names a session the user can simply resume.
797/// Runs SQLite work, so callers on the async runtime wrap it in
798/// `spawn_blocking`.
799pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
800    let limit = limit.clamp(1, MAX_WIKI_LIMIT);
801    if !index_is_writable() {
802        // Nothing to answer from: either this process has no index of its own
803        // or the one on disk is at another version. The status beside the rows
804        // says which.
805        return Ok(Vec::new());
806    }
807    let connection = open_readonly()?;
808    let query = query.trim();
809    if query.is_empty() {
810        let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
811            .context("list recent SessionWiki sessions")?;
812        return Ok(rows
813            .into_iter()
814            .map(|row| wiki_row(row, None, live))
815            .collect());
816    }
817    let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
818        sessionwiki::index::search_like(&connection, query, limit, None, None)
819    } else {
820        sessionwiki::index::search(&connection, query, limit, None, None)
821    }
822    .context("search the SessionWiki index")?;
823    let mut rows: Vec<WikiRow> = hits
824        .into_iter()
825        .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
826        .collect();
827    // SessionWiki searches message text alone, so a session known by a title
828    // or a project that is never said out loud would be unfindable. Those
829    // matches follow the full-text ones rather than displacing them.
830    let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
831    for row in named_like(&connection, query)? {
832        if rows.len() >= limit {
833            break;
834        }
835        if found.contains(&row.session_id) {
836            continue;
837        }
838        rows.push(wiki_row(row, None, live));
839    }
840    Ok(rows)
841}
842
843/// How far back a title or project match looks. Those columns have no index of
844/// their own, so this is a scan of the most recent sessions rather than of the
845/// whole corpus.
846const NAME_SCAN_LIMIT: usize = 2_000;
847
848/// Indexed sessions whose title or project contains the query, ignoring case.
849fn named_like(
850    connection: &rusqlite::Connection,
851    query: &str,
852) -> Result<Vec<sessionwiki::index::SessionRow>> {
853    let needle = query.to_lowercase();
854    let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
855        .context("list recent SessionWiki sessions")?;
856    Ok(rows
857        .into_iter()
858        .filter(|row| {
859            row.title.to_lowercase().contains(&needle)
860                || row.project.to_lowercase().contains(&needle)
861        })
862        .collect())
863}
864
865/// The briefing for one indexed session, or `None` when the id names none.
866pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
867    if !index_is_writable() {
868        return Ok(None);
869    }
870    let connection = open_readonly()?;
871    let Some(row) = row_by_id(&connection, id)? else {
872        return Ok(None);
873    };
874    let session = sessionwiki::index::session_from_index(&connection, &row)
875        .context("read an indexed session")?;
876    Ok(Some(sessionwiki::commands::brief_markdown(
877        &session, max_chars, true,
878    )))
879}
880
881/// The passages of one indexed session that match `query`, or `None` when the
882/// id names no indexed session.
883///
884/// Every matching message is returned with `context_messages` neighbours on
885/// each side; overlapping groups are merged and each group's first block says
886/// how many messages were skipped before it. Each block's text is capped at
887/// `per_message_chars` characters, keeping the window around its first match.
888pub fn transcript_hits(
889    id: &str,
890    query: &str,
891    context_messages: usize,
892    per_message_chars: usize,
893) -> Result<Option<WikiHitTranscript>> {
894    if !index_is_writable() {
895        return Ok(None);
896    }
897    let connection = open_readonly()?;
898    let Some(row) = row_by_id(&connection, id)? else {
899        return Ok(None);
900    };
901    let session = sessionwiki::index::session_from_index(&connection, &row)
902        .context("read an indexed session")?;
903    Ok(Some(hit_transcript(
904        &session,
905        query,
906        context_messages,
907        per_message_chars,
908    )))
909}
910
911/// The matching passages of one loaded session. Pure, so the excerpt rules can
912/// be tested without an index on disk.
913///
914/// Matching is a case-insensitive substring search over NFC-normalised,
915/// redacted text. That reproduces what the index found: its full-text table
916/// uses a trigram tokenizer, which is substring matching for queries of three
917/// or more characters, and shorter queries already go through a `LIKE` scan.
918/// Redaction is the same `sessionwiki::redact` pass `brief_markdown` makes, so
919/// a credential that never reaches a briefing never reaches a preview either.
920fn hit_transcript(
921    session: &Session,
922    query: &str,
923    context_messages: usize,
924    per_message_chars: usize,
925) -> WikiHitTranscript {
926    let needle = sessionwiki::util::nfc(query.trim()).to_lowercase();
927    if needle.is_empty() || session.messages.is_empty() {
928        return WikiHitTranscript::default();
929    }
930    let texts: Vec<String> = session
931        .messages
932        .iter()
933        .map(|message| {
934            sessionwiki::redact::redact(&sessionwiki::util::nfc(message.text.trim())).into_owned()
935        })
936        .collect();
937    // Tool output never anchors a passage. It is machine chatter the reader
938    // did not write and does not read: a hit buried in it opens the preview on
939    // a wall of command output, and the preview collapses tool runs anyway, so
940    // a match inside one could not be shown. Tool messages still appear as
941    // context around a real match.
942    let found: Vec<Vec<(usize, usize)>> = texts
943        .iter()
944        .zip(&session.messages)
945        .map(|(text, message)| match message.role {
946            Role::Tool => Vec::new(),
947            _ => matches_in(text, &needle),
948        })
949        .collect();
950
951    // Merge each match's context window into groups of consecutive messages.
952    // Windows one apart are merged too: "0 messages omitted" is noise.
953    let last = texts.len() - 1;
954    let mut groups: Vec<(usize, usize)> = Vec::new();
955    for index in (0..texts.len()).filter(|index| !found[*index].is_empty()) {
956        let start = index.saturating_sub(context_messages);
957        let end = (index + context_messages).min(last);
958        match groups.last_mut() {
959            Some(previous) if start <= previous.1 + 1 => previous.1 = previous.1.max(end),
960            _ => groups.push((start, end)),
961        }
962    }
963    if groups.is_empty() {
964        return WikiHitTranscript::default();
965    }
966
967    let mut blocks: Vec<WikiHitBlock> = Vec::new();
968    let mut previous_end: Option<usize> = None;
969    for (start, end) in &groups {
970        let omitted = match previous_end {
971            Some(previous) => start - previous - 1,
972            None => *start,
973        };
974        for index in *start..=*end {
975            let (text, hits, truncated) = excerpt(&texts[index], &found[index], per_message_chars);
976            blocks.push(WikiHitBlock {
977                role: role_name(session.messages[index].role).to_owned(),
978                text,
979                hits,
980                omitted_before: if index == *start { omitted } else { 0 },
981                truncated,
982            });
983        }
984        previous_end = Some(*end);
985    }
986    WikiHitTranscript {
987        blocks,
988        omitted_after: last - previous_end.unwrap_or(last),
989    }
990}
991
992fn role_name(role: Role) -> &'static str {
993    match role {
994        Role::User => "user",
995        Role::Assistant => "assistant",
996        Role::Tool => "tool",
997    }
998}
999
1000/// Byte ranges of every non-overlapping case-insensitive occurrence of an
1001/// already-lowercased needle.
1002///
1003/// Lowercasing can change a string's length (`İ` lowercases to two chars), so
1004/// the search carries a map from each lowercased byte back to the byte that
1005/// starts the character it came from. The returned ranges are therefore
1006/// offsets into `text` itself, on character boundaries.
1007fn matches_in(text: &str, needle: &str) -> Vec<(usize, usize)> {
1008    let mut lowered = String::with_capacity(text.len());
1009    let mut origin: Vec<usize> = Vec::with_capacity(text.len() + 1);
1010    for (index, character) in text.char_indices() {
1011        let before = lowered.len();
1012        lowered.extend(character.to_lowercase());
1013        origin.resize(origin.len() + (lowered.len() - before), index);
1014    }
1015    origin.push(text.len());
1016
1017    let mut hits: Vec<(usize, usize)> = Vec::new();
1018    let mut from = 0;
1019    while let Some(offset) = lowered[from..].find(needle) {
1020        let start = from + offset;
1021        from = start + needle.len();
1022        let begin = origin[start];
1023        let mut end = origin[from];
1024        if end <= begin {
1025            // The whole match sat inside one character's lowercase expansion.
1026            end = text[begin..]
1027                .chars()
1028                .next()
1029                .map_or(begin, |character| begin + character.len_utf8());
1030        }
1031        hits.push((begin, end));
1032    }
1033    hits
1034}
1035
1036/// One message capped at `per_message_chars` characters, keeping the window
1037/// around its first match, with the hit ranges rebased onto what is kept.
1038fn excerpt(
1039    text: &str,
1040    hits: &[(usize, usize)],
1041    per_message_chars: usize,
1042) -> (String, Vec<(usize, usize)>, bool) {
1043    let total = text.chars().count();
1044    if per_message_chars == 0 || total <= per_message_chars {
1045        return (text.to_owned(), hits.to_vec(), false);
1046    }
1047    // A quarter of the budget of lead-in, so the hit reads in context rather
1048    // than starting the excerpt.
1049    let first = hits
1050        .first()
1051        .map_or(0, |(start, _)| text[..*start].chars().count());
1052    let mut window_start = first.saturating_sub(per_message_chars / 4);
1053    window_start = window_start.min(total - per_message_chars);
1054    let begin = byte_of_char(text, window_start);
1055    let end = byte_of_char(text, window_start + per_message_chars);
1056    let kept = hits
1057        .iter()
1058        .filter_map(|(start, stop)| {
1059            let start = (*start).max(begin);
1060            let stop = (*stop).min(end);
1061            if start < stop {
1062                Some((start - begin, stop - begin))
1063            } else {
1064                None
1065            }
1066        })
1067        .collect();
1068    (text[begin..end].to_owned(), kept, true)
1069}
1070
1071fn byte_of_char(text: &str, char_index: usize) -> usize {
1072    text.char_indices()
1073        .nth(char_index)
1074        .map_or(text.len(), |(offset, _)| offset)
1075}
1076
1077/// What a restore needs from the index: the transcript as a snapshot the
1078/// compaction pipeline accepts, plus the title and project of the session it
1079/// came from.
1080pub struct ArchivedSession {
1081    pub title: String,
1082    /// The project directory the session ran in, when the row names one that
1083    /// still exists.
1084    pub project_directory: Option<PathBuf>,
1085    pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
1086}
1087
1088/// Load one indexed session for restore, or `None` when the id names none.
1089pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
1090    if !index_is_writable() {
1091        return Ok(None);
1092    }
1093    let connection = open_readonly()?;
1094    let Some(row) = row_by_id(&connection, id)? else {
1095        return Ok(None);
1096    };
1097    let session = sessionwiki::index::session_from_index(&connection, &row)
1098        .context("read an indexed session")?;
1099    let snapshot = snapshot_of(&session)?;
1100    Ok(Some(ArchivedSession {
1101        title: session.title.clone(),
1102        project_directory: project_directory_of(&session.project),
1103        snapshot,
1104    }))
1105}
1106
1107// ---------------------------------------------------------------------------
1108// The archive job
1109// ---------------------------------------------------------------------------
1110
1111/// The stopped sessions that `archive_after_days = older_than_days` has caught,
1112/// children before their parents.
1113///
1114/// A session qualifies when its record is `Stopped`, its last update is at
1115/// least that many days old, and every sub-agent child it still has is being
1116/// archived in the same pass. The child rule is what keeps the pass from
1117/// destroying a session it did not choose: archiving a parent tears its
1118/// children down with it, so a child that is still running, or stopped but not
1119/// yet old enough, holds its parent back until the next pass.
1120///
1121/// Pure over controller state, so the rule can be tested without a daemon.
1122pub fn sessions_ready_to_archive(
1123    sessions: &BTreeMap<String, SessionRecord>,
1124    subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1125    now: DateTime<Utc>,
1126    older_than_days: u32,
1127) -> Vec<String> {
1128    let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
1129    let aged = |session_id: &String| {
1130        sessions.get(session_id).is_some_and(|record| {
1131            record.state == mj_core::state::SessionState::Stopped
1132                && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
1133        })
1134    };
1135    let selected: BTreeSet<String> = sessions
1136        .keys()
1137        .filter(|session_id| aged(session_id))
1138        .filter(|session_id| {
1139            subagents
1140                .values()
1141                .filter(|child| &&child.parent_session_id == session_id)
1142                // A child whose record is already gone holds nothing open.
1143                .filter(|child| sessions.contains_key(&child.child_session_id))
1144                .all(|child| aged(&child.child_session_id))
1145        })
1146        .cloned()
1147        .collect();
1148    let mut ordered: Vec<String> = selected.iter().cloned().collect();
1149    ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
1150    ordered
1151}
1152
1153/// How many sub-agent parents a session has above it. Deeper sessions are
1154/// archived first so a parent never tears down a child the pass still has to
1155/// visit.
1156fn ancestor_depth(
1157    session_id: &str,
1158    subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1159) -> usize {
1160    let mut depth = 0;
1161    let mut current = session_id;
1162    // Bounded by the map: a cycle cannot outlive one pass over every entry.
1163    while let Some(parent) = subagents
1164        .get(current)
1165        .map(|child| child.parent_session_id.as_str())
1166    {
1167        depth += 1;
1168        if depth > subagents.len() {
1169            break;
1170        }
1171        current = parent;
1172    }
1173    depth
1174}
1175
1176/// How much disk Mjolnir's own copies of sessions use, and how much an
1177/// `archive_after_days` value would free. "Mjolnir's own copy" is the
1178/// checkpoint archive plus the session's image attachments; the conversation
1179/// itself lives in the SessionWiki index and is not counted, because archiving
1180/// keeps it. The type lives in `mj-core` so the terminal UI can name it too.
1181pub use mj_core::state::ArchiveSpacePreview;
1182
1183/// The space every session uses now and, when `older_than_days` is set, the
1184/// space archiving after that many days would reclaim.
1185///
1186/// The reclaim figure uses the archive job's own selection rule but not its
1187/// "is it indexed yet" gate: that gate depends on how far the hourly index
1188/// sync has got, so applying it would make the estimate swing between zero and
1189/// the true value while the first index builds. This answers what the policy
1190/// would reclaim, not what the next tick happens to reclaim.
1191///
1192/// Walks the filesystem, so callers on the async runtime must run it in a
1193/// blocking task.
1194pub fn archive_space_preview(older_than_days: Option<u32>) -> Result<ArchiveSpacePreview> {
1195    let controller =
1196        Controller::load().context("load the session records to size their storage")?;
1197    Ok(archive_space_over(
1198        &mj_core::config::sessions_dir(),
1199        &controller.state.sessions,
1200        &controller.state.subagents,
1201        Utc::now(),
1202        older_than_days,
1203    ))
1204}
1205
1206/// The sizing itself, over given records and a given sessions directory, so it
1207/// can be tested without the live data directory.
1208fn archive_space_over(
1209    sessions_root: &Path,
1210    sessions: &BTreeMap<String, SessionRecord>,
1211    subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1212    now: DateTime<Utc>,
1213    older_than_days: Option<u32>,
1214) -> ArchiveSpacePreview {
1215    let mut preview = ArchiveSpacePreview {
1216        sessions: sessions.len(),
1217        bytes: sessions
1218            .iter()
1219            .map(|(session_id, record)| session_bytes(sessions_root, session_id, record))
1220            .sum(),
1221        reclaimable_sessions: 0,
1222        reclaimable_bytes: 0,
1223    };
1224    if let Some(days) = older_than_days {
1225        let aged = sessions_ready_to_archive(sessions, subagents, now, days);
1226        preview.reclaimable_sessions = aged.len();
1227        preview.reclaimable_bytes = aged
1228            .iter()
1229            .filter_map(|session_id| {
1230                sessions
1231                    .get(session_id)
1232                    .map(|record| session_bytes(sessions_root, session_id, record))
1233            })
1234            .sum();
1235    }
1236    preview
1237}
1238
1239/// What archiving one session would free: its checkpoint archive and its
1240/// attachments. Anything already missing counts as zero.
1241fn session_bytes(sessions_root: &Path, session_id: &str, record: &SessionRecord) -> u64 {
1242    let checkpoint = record
1243        .checkpoint
1244        .as_ref()
1245        .and_then(|checkpoint| std::fs::metadata(&checkpoint.archive_path).ok())
1246        .filter(|metadata| metadata.is_file())
1247        .map(|metadata| metadata.len())
1248        .unwrap_or(0);
1249    let attachments = sessions_root
1250        .join(session_id)
1251        .join(mj_core::attachment::ATTACHMENT_DIR);
1252    let attachments = crate::import::claude::directory_size(&attachments).unwrap_or(0);
1253    checkpoint.saturating_add(attachments)
1254}
1255
1256/// Which of `session_ids` the index holds under this instance's own key, with
1257/// at least one message and not already archived.
1258///
1259/// This is the gate the archive job will not cross: Mjolnir only deletes its
1260/// own copy of a conversation SessionWiki has actually stored. Runs SQLite
1261/// work, so callers on the async runtime wrap it in `spawn_blocking`.
1262pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
1263    if !index_is_writable() {
1264        // An index this daemon will not open holds nothing it may act on, and
1265        // the archive job deletes data, so it must find nothing here.
1266        return Ok(BTreeSet::new());
1267    }
1268    let connection = open_readonly()?;
1269    let sessions_dir = mj_core::config::sessions_dir();
1270    let mut indexed = BTreeSet::new();
1271    for session_id in session_ids {
1272        let key = format!("{}/{session_id}", sessions_dir.display());
1273        let rows = sessionwiki::index::resolve(&connection, session_id)
1274            .context("look up a stopped session in the SessionWiki index")?;
1275        if rows
1276            .iter()
1277            .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
1278        {
1279            indexed.insert(session_id.clone());
1280        }
1281    }
1282    Ok(indexed)
1283}
1284
1285fn open_readonly() -> Result<rusqlite::Connection> {
1286    sessionwiki::index::open_readonly().context("open the SessionWiki index")
1287}
1288
1289/// The one row an id names exactly. `resolve` matches prefixes, which is right
1290/// for a person typing and wrong for a client passing an id back.
1291fn row_by_id(
1292    connection: &rusqlite::Connection,
1293    id: &str,
1294) -> Result<Option<sessionwiki::index::SessionRow>> {
1295    Ok(sessionwiki::index::resolve(connection, id)
1296        .context("look up an indexed session")?
1297        .into_iter()
1298        .find(|row| row.session_id == id))
1299}
1300
1301fn wiki_row(
1302    row: sessionwiki::index::SessionRow,
1303    snippet: Option<String>,
1304    live: &BTreeSet<String>,
1305) -> WikiRow {
1306    // Only this daemon's own sessions can be live here, and only under the key
1307    // shape the adapter writes: the checkpoint directory and the session id.
1308    let hel_session_id = (row.tool == TOOL)
1309        .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
1310        .filter(|session_id| live.contains(session_id));
1311    let native_id = sessionwiki::index::native_id_of(&row.path);
1312    WikiRow {
1313        id: row.session_id,
1314        tool: row.tool,
1315        project: row.project,
1316        title: row.title,
1317        started: row.started,
1318        msgs: row.msg_count,
1319        preview: row.preview,
1320        archived: row.archived,
1321        native_id,
1322        snippet,
1323        hel_session_id,
1324    }
1325}
1326
1327/// The project a restored session should open.
1328///
1329/// A Mjolnir session runs in a managed worktree under the repository it was
1330/// started from, and that worktree is gone once the session is archived. The
1331/// repository above it is what the user still has, so a worktree path is
1332/// reduced to it. Any other path is used as it stands, and a path that no
1333/// longer exists is left for the caller to replace.
1334fn project_directory_of(project: &str) -> Option<PathBuf> {
1335    if project.trim().is_empty() {
1336        return None;
1337    }
1338    let path = PathBuf::from(project);
1339    let repository = path
1340        .ancestors()
1341        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1342        .and_then(std::path::Path::parent)
1343        .map(std::path::Path::to_path_buf)
1344        .unwrap_or(path);
1345    repository.is_dir().then_some(repository)
1346}
1347
1348/// Rebuild an indexed transcript as a canonical snapshot.
1349///
1350/// The snapshot is only ever read by the compaction pipeline, which wants
1351/// turns: a user message opens a turn and assistant and tool items attach to
1352/// it. Messages before the first user message therefore have nowhere to go and
1353/// are dropped, and a session with no user message at all cannot be restored.
1354fn snapshot_of(
1355    session: &sessionwiki::model::Session,
1356) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1357    use mj_core::archive::{
1358        CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1359        CanonicalTranscriptBody, CanonicalTranscriptItem,
1360    };
1361
1362    let started_ms = session
1363        .started
1364        .map(|time| time.timestamp_millis())
1365        .unwrap_or_default();
1366    let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1367    for message in &session.messages {
1368        let text = message.text.trim();
1369        if text.is_empty() {
1370            continue;
1371        }
1372        // Compaction attaches assistant and tool items to the open turn, so an
1373        // item before the first user message would be dropped anyway.
1374        if transcript.is_empty() && message.role != Role::User {
1375            continue;
1376        }
1377        let position = transcript.len() as u64 + 1;
1378        let body = match message.role {
1379            Role::User => CanonicalTranscriptBody::User {
1380                content: vec![serde_json::json!({"type": "text", "text": text})],
1381            },
1382            Role::Assistant => CanonicalTranscriptBody::Agent {
1383                chunks: vec![serde_json::json!({
1384                    "content": {"type": "text", "text": text}
1385                })],
1386                streaming: false,
1387            },
1388            // The index keeps a tool call's title and nothing else, which is
1389            // what the transcript showed the user.
1390            Role::Tool => CanonicalTranscriptBody::Tool {
1391                call: serde_json::json!({
1392                    "toolCallId": format!("wiki-tool-{position}"),
1393                    "title": text,
1394                    "status": "completed"
1395                }),
1396                terminal_outputs: Vec::new(),
1397                terminal_refs: Vec::new(),
1398                presentation: None,
1399            },
1400        };
1401        let created_at_ms = message
1402            .ts
1403            .map(|time| time.timestamp_millis())
1404            .unwrap_or(started_ms);
1405        transcript.push(CanonicalTranscriptItem {
1406            stable_id: format!("wiki-{position}"),
1407            position,
1408            // The validator wants an ordinal on agent messages and on nothing
1409            // else; one event per item makes the item's own position right.
1410            latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1411                .then_some(position),
1412            created_at_ms,
1413            last_changed_at_ms: created_at_ms,
1414            body,
1415        });
1416    }
1417    anyhow::ensure!(
1418        !transcript.is_empty(),
1419        "the archived session has no prompt to restore from"
1420    );
1421
1422    let event_frontier = transcript.len() as u64;
1423    let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1424    Ok(CanonicalSessionSnapshot {
1425        event_frontier,
1426        // Not a relay frontier, so there is no recorded digest to carry. It has
1427        // to be a well-formed non-genesis digest, and deriving it from the
1428        // session makes two restores of one session agree.
1429        event_frontier_digest: {
1430            use sha2::Digest;
1431            mj_core::hex::lower_hex(sha2::Sha256::digest(
1432                format!("sessionwiki:{}", session.id).as_bytes(),
1433            ))
1434        },
1435        session: CanonicalSessionState {
1436            execution: CanonicalExecutionState::Idle,
1437            last_activity_at_ms,
1438            session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1439            configuration: BTreeMap::new(),
1440        },
1441        transcript,
1442        queued_prompts: Vec::new(),
1443    })
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448    use std::collections::BTreeMap;
1449    use std::path::Path;
1450
1451    use mj_checkpoint::archive::{
1452        ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1453        CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1454        TargetManifest, write_archive_atomic,
1455    };
1456
1457    use super::*;
1458
1459    fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1460        // Only an agent message carries a content ordinal; the snapshot
1461        // validator rejects one on any other item and demands one here.
1462        let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1463        CanonicalTranscriptItem {
1464            stable_id: format!("item-{position}"),
1465            position,
1466            latest_content_event_ordinal: streamed.then_some(position),
1467            created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1468            last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1469            body,
1470        }
1471    }
1472
1473    /// A managed checkpoint with one prompt, one reply, one tool call, and one
1474    /// thought, which is every transcript shape the adapter decides about.
1475    fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1476        let path = directory.join(format!(
1477            "{session_id}-{frontier}-archive-{}.hel.zip",
1478            "0".repeat(32)
1479        ));
1480        write_archive_atomic(
1481            &path,
1482            &ArchiveInput {
1483                session: SessionManifest {
1484                    id: session_id.into(),
1485                    title: "indexed session".into(),
1486                    harness_kind: mj_core::config::HarnessKind::Codex,
1487                    profile_id: "codex".into(),
1488                    native_session_id: "native-session".into(),
1489                    created_at: "2026-09-01T00:00:00Z".into(),
1490                    checkpointed_at: "2026-09-01T01:00:00Z".into(),
1491                    hel_version: "test".into(),
1492                    relay_version: "test".into(),
1493                    adapter_version: "test".into(),
1494                },
1495                target: TargetManifest {
1496                    template_id: "local".into(),
1497                    target_kind: "local-bare".into(),
1498                    details: BTreeMap::new(),
1499                },
1500                bundle: BundleManifest {
1501                    id: "project".into(),
1502                    primary_repository: "project".into(),
1503                },
1504                canonical_session: CanonicalSessionSnapshot {
1505                    event_frontier: 4,
1506                    event_frontier_digest: "a".repeat(64),
1507                    session: CanonicalSessionState {
1508                        execution: CanonicalExecutionState::Idle,
1509                        last_activity_at_ms: Some(1_700_000_000_004),
1510                        session_title: Some("snapshot title".into()),
1511                        configuration: BTreeMap::new(),
1512                    },
1513                    transcript: vec![
1514                        item(
1515                            1,
1516                            CanonicalTranscriptBody::User {
1517                                content: vec![serde_json::json!({
1518                                    "type": "text",
1519                                    "text": "index this session"
1520                                })],
1521                            },
1522                        ),
1523                        item(
1524                            2,
1525                            CanonicalTranscriptBody::Thought {
1526                                chunks: vec![serde_json::json!({
1527                                    "content": {"type": "text", "text": "pondering"}
1528                                })],
1529                                streaming: false,
1530                            },
1531                        ),
1532                        item(
1533                            3,
1534                            CanonicalTranscriptBody::Tool {
1535                                call: serde_json::json!({
1536                                    "toolCallId": "call-1",
1537                                    "title": "Read config.toml",
1538                                    "status": "completed"
1539                                }),
1540                                terminal_outputs: Vec::new(),
1541                                terminal_refs: Vec::new(),
1542                                presentation: None,
1543                            },
1544                        ),
1545                        item(
1546                            4,
1547                            CanonicalTranscriptBody::Agent {
1548                                chunks: vec![serde_json::json!({
1549                                    "content": {"type": "text", "text": "done"}
1550                                })],
1551                                streaming: false,
1552                            },
1553                        ),
1554                    ],
1555                    queued_prompts: Vec::new(),
1556                },
1557                native_artifacts: Vec::new(),
1558                repositories: Vec::new(),
1559            },
1560        )
1561        .unwrap();
1562    }
1563
1564    fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1565        adapter_with_live(directory, session_id, BTreeMap::new())
1566    }
1567
1568    fn adapter_with_live(
1569        directory: &Path,
1570        session_id: &str,
1571        live: BTreeMap<String, i64>,
1572    ) -> MjolnirAdapter {
1573        let record = SessionRecord {
1574            id: session_id.into(),
1575            ..record_template()
1576        };
1577        MjolnirAdapter {
1578            sessions_dir: directory.to_path_buf(),
1579            sessions: std::sync::Mutex::new(Sessions {
1580                records: BTreeMap::from([(session_id.to_owned(), record)]),
1581                subagent_ids: BTreeSet::new(),
1582                live,
1583            }),
1584            reload: false,
1585        }
1586    }
1587
1588    fn record_template() -> SessionRecord {
1589        SessionRecord {
1590            build_cache: None,
1591            container_workspace: None,
1592            mjolnir_subagents: None,
1593            create_managed_worktree: None,
1594            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1595            archived: false,
1596            container_cpus: None,
1597            container_memory: None,
1598            id: "0123456789abcdef0123456789abcdef".into(),
1599            title: "indexed session".into(),
1600            harness_kind: mj_core::config::HarnessKind::Codex,
1601            last_profile: "codex".into(),
1602            bundle_id: "project".into(),
1603            project_directory: Some(PathBuf::from("/home/dev/project")),
1604            managed_worktree: None,
1605            target_template_id: "local-bare".into(),
1606            resource_allocation: None,
1607            additional_mounts: Vec::new(),
1608            state: mj_core::state::SessionState::Stopped,
1609            target: None,
1610            native_session_id: Some("native-session".into()),
1611            acp_session_title: Some("the harness title".into()),
1612            session_title_override: None,
1613            created_at: "2026-09-01T00:00:00Z".into(),
1614            updated_at: "2026-09-01T01:00:00Z".into(),
1615            viewed_through_event_ordinal: 0,
1616            draft_input: String::new(),
1617            last_error: None,
1618            last_checkpoint_error: None,
1619            checkpoint: None,
1620        }
1621    }
1622
1623    #[test]
1624    fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1625        let directory = tempfile::tempdir().unwrap();
1626        let session_id = "0123456789abcdef0123456789abcdef";
1627        write_archive(directory.path(), session_id, 1);
1628        write_archive(directory.path(), session_id, 7);
1629        let adapter = adapter(directory.path(), session_id);
1630
1631        let store = adapter.store().expect("the adapter is a shared store");
1632        let key = format!("{}/{session_id}", directory.path().display());
1633        assert_eq!(
1634            store
1635                .keys
1636                .iter()
1637                .map(|(key, _)| key.as_str())
1638                .collect::<Vec<_>>(),
1639            vec![key.as_str()]
1640        );
1641        assert!(!store.had_error);
1642        assert_eq!(store.files.len(), 1);
1643        assert!(
1644            store.files[0]
1645                .file_name()
1646                .unwrap()
1647                .to_str()
1648                .unwrap()
1649                .contains("-7-archive-"),
1650            "the newest checkpoint is the one indexed: {:?}",
1651            store.files[0]
1652        );
1653        assert_eq!(
1654            adapter.reconcile_scope(),
1655            Some(format!("{}/", directory.path().display()))
1656        );
1657
1658        let session = adapter.parse_key(&key).unwrap();
1659        assert_eq!(session.id, session_id);
1660        assert_eq!(session.tool, "mjolnir");
1661        assert_eq!(session.path, PathBuf::from(&key));
1662        assert_eq!(session.project, "/home/dev/project");
1663        assert_eq!(session.title, "the harness title");
1664        assert!(!session.subagent);
1665        assert_eq!(
1666            session
1667                .messages
1668                .iter()
1669                .map(|message| (message.role, message.text.as_str()))
1670                .collect::<Vec<_>>(),
1671            vec![
1672                (Role::User, "index this session"),
1673                (Role::Tool, "Read config.toml"),
1674                (Role::Assistant, "done"),
1675            ]
1676        );
1677    }
1678
1679    fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1680        use mj_core::transcript::{TranscriptBody, TranscriptItem};
1681        let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1682        let mut push = |position: u64, body: TranscriptBody| {
1683            let streamed = matches!(body, TranscriptBody::Agent { .. });
1684            projected
1685                .transcript
1686                .push(std::sync::Arc::new(TranscriptItem {
1687                    stable_id: format!("item-{position}"),
1688                    position,
1689                    latest_content_event_ordinal: streamed.then_some(position),
1690                    created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1691                    last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1692                    body,
1693                }));
1694        };
1695        push(
1696            1,
1697            TranscriptBody::User {
1698                content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1699            },
1700        );
1701        push(
1702            2,
1703            TranscriptBody::Thought {
1704                chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1705                streaming: false,
1706            },
1707        );
1708        push(
1709            3,
1710            TranscriptBody::Tool {
1711                call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1712                terminal_outputs: Vec::new(),
1713                terminal_refs: Vec::new(),
1714                presentation: None,
1715            },
1716        );
1717        push(
1718            4,
1719            TranscriptBody::Agent {
1720                chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1721                streaming: false,
1722            },
1723        );
1724        projected.session_title = Some("the live title".into());
1725        projected
1726    }
1727
1728    /// A session that has never been checkpointed is indexed from the
1729    /// daemon's own projection, with the same roles a checkpoint would give.
1730    #[test]
1731    fn a_running_session_is_indexed_from_its_stored_transcript() {
1732        let session_id = "0123456789abcdef0123456789abcdef";
1733        assert_eq!(
1734            projected_messages(&projection(session_id))
1735                .iter()
1736                .map(|message| (message.role, message.text.clone()))
1737                .collect::<Vec<_>>(),
1738            vec![
1739                (Role::User, "still talking".to_owned()),
1740                (Role::Tool, "Read README.md".to_owned()),
1741                (Role::Assistant, "reading".to_owned()),
1742            ],
1743            "a thought is skipped and every other item keeps its role"
1744        );
1745    }
1746
1747    /// A running session is listed under the same key as a stopped one, with
1748    /// its own change token, so it is searchable before it is ever closed and
1749    /// reconciliation never archives it. When it stops, the key stays and the
1750    /// checkpoint becomes its source.
1751    #[test]
1752    fn a_running_session_is_listed_with_its_own_change_token() {
1753        let directory = tempfile::tempdir().unwrap();
1754        let running = "0123456789abcdef0123456789abcdef";
1755        let never_checkpointed = "fedcba9876543210fedcba9876543210";
1756        write_archive(directory.path(), running, 3);
1757        let live = adapter_with_live(
1758            directory.path(),
1759            running,
1760            BTreeMap::from([
1761                (running.to_owned(), 1_900_000_000),
1762                (never_checkpointed.to_owned(), 1_900_000_001),
1763            ]),
1764        );
1765
1766        let store = live.store().expect("the adapter is a shared store");
1767        let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
1768        assert_eq!(
1769            store.keys,
1770            vec![
1771                (key_of(running), 1_900_000_000),
1772                (key_of(never_checkpointed), 1_900_000_001),
1773            ],
1774            "a live session's own token replaces the checkpoint's"
1775        );
1776
1777        // Once it stops it leaves the live set, and the checkpoint's own
1778        // modification time is the token again.
1779        let stopped = adapter(directory.path(), running);
1780        let keys = stopped.store().expect("a shared store").keys;
1781        assert_eq!(keys.len(), 1);
1782        assert_eq!(keys[0].0, key_of(running));
1783        assert_ne!(keys[0].1, 1_900_000_000);
1784        assert_eq!(
1785            stopped.parse_key(&key_of(running)).unwrap().title,
1786            "the harness title",
1787            "a stopped session is parsed from its checkpoint"
1788        );
1789    }
1790
1791    /// Renaming a session leaves its conversation untouched, so only the
1792    /// record's own last update can tell the index the title moved.
1793    #[test]
1794    fn a_rename_moves_a_session_change_token() {
1795        let directory = tempfile::tempdir().unwrap();
1796        let session_id = "0123456789abcdef0123456789abcdef";
1797        write_archive(directory.path(), session_id, 1);
1798        let adapter = adapter(directory.path(), session_id);
1799        let before = adapter.store().expect("a shared store").keys[0].1;
1800
1801        {
1802            let mut sessions = adapter.sessions.lock().unwrap();
1803            let record = sessions.records.get_mut(session_id).unwrap();
1804            record.session_title_override = Some("the new name".into());
1805            record.updated_at = "2099-01-01T00:00:00Z".into();
1806        }
1807        let after = adapter.store().expect("a shared store").keys[0].1;
1808        assert!(
1809            after > before,
1810            "a renamed session is re-indexed: {before} then {after}"
1811        );
1812        assert_eq!(
1813            adapter
1814                .parse_key(&format!("{}/{session_id}", directory.path().display()))
1815                .unwrap()
1816                .title,
1817            "the new name"
1818        );
1819    }
1820
1821    fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
1822        Session {
1823            id: "0123456789abcdef0123456789abcdef".into(),
1824            tool: "mjolnir",
1825            path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
1826            project: "/home/dev/project".into(),
1827            started: DateTime::from_timestamp_millis(1_700_000_000_000),
1828            ended: None,
1829            title: "the archived session".into(),
1830            subagent: false,
1831            messages: messages
1832                .into_iter()
1833                .map(|(role, text)| Message {
1834                    role,
1835                    text: text.to_owned(),
1836                    ts: None,
1837                })
1838                .collect(),
1839            touched: Vec::new(),
1840            edits: Vec::new(),
1841        }
1842    }
1843
1844    /// A hit is found whatever the case of the query or of the transcript, and
1845    /// the reported range covers the matched text in the returned block.
1846    #[test]
1847    fn transcript_hits_locates_case_insensitive_matches() {
1848        let session = indexed(vec![
1849            (Role::User, "Make the Tests green"),
1850            (Role::Assistant, "the tests are green now"),
1851        ]);
1852
1853        let found = hit_transcript(&session, "TESTS", 0, 4_000);
1854
1855        assert_eq!(found.blocks.len(), 2, "both messages contain the query");
1856        assert_eq!(found.blocks[0].role, "user");
1857        let (start, end) = found.blocks[0].hits[0];
1858        assert_eq!(&found.blocks[0].text[start..end], "Tests");
1859        let (start, end) = found.blocks[1].hits[0];
1860        assert_eq!(&found.blocks[1].text[start..end], "tests");
1861        assert!(!found.blocks[0].truncated);
1862        assert_eq!(found.omitted_after, 0);
1863    }
1864
1865    /// Context messages come back around each hit, with the gap between two
1866    /// groups counted rather than silently closed.
1867    #[test]
1868    fn transcript_hits_keeps_context_and_marks_omissions() {
1869        let session = indexed(vec![
1870            (Role::User, "zero"),
1871            (Role::Assistant, "one needle one"),
1872            (Role::Tool, "two"),
1873            (Role::User, "three"),
1874            (Role::Assistant, "four"),
1875            (Role::Tool, "five"),
1876            (Role::User, "six needle six"),
1877            (Role::Assistant, "seven"),
1878            (Role::User, "eight"),
1879        ]);
1880
1881        let found = hit_transcript(&session, "needle", 1, 4_000);
1882
1883        let shown: Vec<(&str, &str, usize)> = found
1884            .blocks
1885            .iter()
1886            .map(|block| {
1887                (
1888                    block.role.as_str(),
1889                    block.text.as_str(),
1890                    block.omitted_before,
1891                )
1892            })
1893            .collect();
1894        assert_eq!(
1895            shown,
1896            vec![
1897                ("user", "zero", 0),
1898                ("assistant", "one needle one", 0),
1899                ("tool", "two", 0),
1900                ("tool", "five", 2),
1901                ("user", "six needle six", 0),
1902                ("assistant", "seven", 0),
1903            ]
1904        );
1905        assert_eq!(found.omitted_after, 1, "the last message is not shown");
1906        assert!(found.blocks[0].hits.is_empty(), "context has no hits");
1907    }
1908
1909    /// A query that only occurs in tool output finds nothing, and a tool
1910    /// message beside a real match still comes back as context. Tool text is
1911    /// machine chatter: anchoring a passage on it opens the preview on command
1912    /// output the reader never wrote, and the preview collapses tool runs, so
1913    /// the match could not be shown even if it were returned.
1914    #[test]
1915    fn transcript_hits_never_anchor_on_tool_output() {
1916        let session = indexed(vec![
1917            (Role::User, "make it build"),
1918            (Role::Tool, "cargo build --needle"),
1919            (Role::Assistant, "it builds"),
1920        ]);
1921
1922        let only_in_a_tool = hit_transcript(&session, "needle", 1, 4_000);
1923        assert!(
1924            only_in_a_tool.blocks.is_empty(),
1925            "tool output must not anchor a passage, got {:?}",
1926            only_in_a_tool.blocks
1927        );
1928
1929        let beside_a_match = hit_transcript(&session, "builds", 1, 4_000);
1930        let shown: Vec<(&str, bool)> = beside_a_match
1931            .blocks
1932            .iter()
1933            .map(|block| (block.role.as_str(), !block.hits.is_empty()))
1934            .collect();
1935        assert_eq!(
1936            shown,
1937            vec![("tool", false), ("assistant", true)],
1938            "a tool message is still context around a real match"
1939        );
1940    }
1941
1942    /// A long message is cut down to the caller's budget around its first hit,
1943    /// not from the start, so the match is always in what comes back.
1944    #[test]
1945    fn transcript_hits_window_keeps_the_first_hit() {
1946        let filler = "x".repeat(4_000);
1947        let session = indexed(vec![(Role::User, &format!("{filler} needle {filler}"))]);
1948
1949        let found = hit_transcript(&session, "needle", 0, 100);
1950
1951        let block = &found.blocks[0];
1952        assert!(block.truncated);
1953        assert_eq!(block.text.chars().count(), 100);
1954        assert_eq!(block.hits.len(), 1, "the windowed text keeps its hit");
1955        let (start, end) = block.hits[0];
1956        assert_eq!(&block.text[start..end], "needle");
1957        assert!(
1958            start >= 20,
1959            "the window keeps lead-in before the hit, got {start}"
1960        );
1961    }
1962
1963    /// The snapshot a restore hands to compaction has to satisfy the same
1964    /// validator a real checkpoint does, and has to carry every message in
1965    /// order.
1966    #[test]
1967    fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
1968        let snapshot = snapshot_of(&indexed(vec![
1969            (Role::User, "make the tests green"),
1970            (Role::Tool, "Read src/lib.rs"),
1971            (Role::Assistant, "they are green now"),
1972            (Role::User, "  "),
1973        ]))
1974        .unwrap();
1975
1976        snapshot.validate().expect("the snapshot is well formed");
1977        assert_eq!(snapshot.event_frontier, 3);
1978        assert_eq!(
1979            snapshot.session.session_title.as_deref(),
1980            Some("the archived session")
1981        );
1982        assert!(snapshot.session.last_activity_at_ms.is_some());
1983        let bodies = snapshot
1984            .transcript
1985            .iter()
1986            .map(|item| match &item.body {
1987                mj_core::archive::CanonicalTranscriptBody::User { content } => (
1988                    "user",
1989                    mj_core::transcript::materialized_content_text(content),
1990                ),
1991                mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
1992                    "agent",
1993                    mj_core::transcript::materialized_chunks_text(chunks),
1994                ),
1995                mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
1996                    "tool",
1997                    call["title"].as_str().unwrap_or_default().to_owned(),
1998                ),
1999                _ => ("other", String::new()),
2000            })
2001            .collect::<Vec<_>>();
2002        assert_eq!(
2003            bodies,
2004            vec![
2005                ("user", "make the tests green".to_owned()),
2006                ("tool", "Read src/lib.rs".to_owned()),
2007                ("agent", "they are green now".to_owned()),
2008            ],
2009            "the blank message is dropped and every other one keeps its role"
2010        );
2011    }
2012
2013    /// Compaction attaches assistant and tool items to the open turn, so an
2014    /// index that starts mid-conversation must not produce a snapshot whose
2015    /// first item has no turn to join.
2016    #[test]
2017    fn messages_before_the_first_prompt_are_dropped() {
2018        let snapshot = snapshot_of(&indexed(vec![
2019            (Role::Assistant, "still working"),
2020            (Role::User, "carry on"),
2021        ]))
2022        .unwrap();
2023        assert_eq!(snapshot.transcript.len(), 1);
2024        assert_eq!(snapshot.transcript[0].position, 1);
2025        snapshot.validate().unwrap();
2026
2027        let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
2028        assert!(
2029            error.to_string().contains("no prompt"),
2030            "a session with no prompt cannot be restored: {error}"
2031        );
2032    }
2033
2034    fn record(
2035        session_id: &str,
2036        state: mj_core::state::SessionState,
2037        updated_at: &str,
2038    ) -> SessionRecord {
2039        SessionRecord {
2040            id: session_id.into(),
2041            state,
2042            updated_at: updated_at.into(),
2043            ..record_template()
2044        }
2045    }
2046
2047    fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
2048        mj_core::subagent::SubagentRecord {
2049            child_session_id: child_session_id.into(),
2050            parent_session_id: parent_session_id.into(),
2051            task_name: "task".into(),
2052            profile_id: "codex".into(),
2053            model: None,
2054            effort: None,
2055            working_directory: PathBuf::new(),
2056            initial_prompt: "do the thing".into(),
2057            request_key: "key".into(),
2058            created_at: "2026-09-01T00:00:00Z".into(),
2059            noticed_turn: None,
2060        }
2061    }
2062
2063    fn ready(
2064        sessions: Vec<SessionRecord>,
2065        children: Vec<mj_core::subagent::SubagentRecord>,
2066    ) -> Vec<String> {
2067        let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2068        sessions_ready_to_archive(
2069            &sessions
2070                .into_iter()
2071                .map(|record| (record.id.clone(), record))
2072                .collect(),
2073            &children
2074                .into_iter()
2075                .map(|child| (child.child_session_id.clone(), child))
2076                .collect(),
2077            now,
2078            3,
2079        )
2080    }
2081
2082    /// A session whose checkpoint archive and attachments sit under `root`.
2083    fn sized_session(
2084        root: &Path,
2085        session_id: &str,
2086        updated_at: &str,
2087        checkpoint_bytes: usize,
2088        attachment_bytes: &[usize],
2089    ) -> SessionRecord {
2090        let archive_path = root.join(format!("{session_id}.hel.zip"));
2091        std::fs::write(&archive_path, vec![b'c'; checkpoint_bytes]).unwrap();
2092        if !attachment_bytes.is_empty() {
2093            let attachments = root
2094                .join(session_id)
2095                .join(mj_core::attachment::ATTACHMENT_DIR);
2096            std::fs::create_dir_all(&attachments).unwrap();
2097            for (index, size) in attachment_bytes.iter().enumerate() {
2098                std::fs::write(attachments.join(format!("{index}.png")), vec![b'a'; *size])
2099                    .unwrap();
2100            }
2101        }
2102        SessionRecord {
2103            checkpoint: Some(mj_core::state::CheckpointMetadata {
2104                archive_path,
2105                sha256: "0".repeat(64),
2106                created_at: updated_at.into(),
2107                event_frontier: 1,
2108            }),
2109            ..record(
2110                session_id,
2111                mj_core::state::SessionState::Stopped,
2112                updated_at,
2113            )
2114        }
2115    }
2116
2117    #[test]
2118    fn the_space_preview_sizes_every_session_and_only_the_aged_ones_as_reclaimable() {
2119        let directory = tempfile::tempdir().unwrap();
2120        let root = directory.path();
2121        let sessions: BTreeMap<String, SessionRecord> = [
2122            sized_session(root, "old-stopped", "2026-09-01T00:00:00Z", 1000, &[10, 20]),
2123            sized_session(root, "just-stopped", "2026-09-09T00:00:00Z", 500, &[]),
2124            // A record whose checkpoint file is already gone counts as zero
2125            // rather than failing the whole estimate.
2126            SessionRecord {
2127                checkpoint: Some(mj_core::state::CheckpointMetadata {
2128                    archive_path: root.join("missing.hel.zip"),
2129                    sha256: "0".repeat(64),
2130                    created_at: "2026-09-01T00:00:00Z".into(),
2131                    event_frontier: 1,
2132                }),
2133                ..record(
2134                    "lost-checkpoint",
2135                    mj_core::state::SessionState::Stopped,
2136                    "2026-09-01T00:00:00Z",
2137                )
2138            },
2139        ]
2140        .into_iter()
2141        .map(|record| (record.id.clone(), record))
2142        .collect();
2143        let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2144
2145        let all = archive_space_over(root, &sessions, &BTreeMap::new(), now, None);
2146        assert_eq!(all.sessions, 3);
2147        assert_eq!(all.bytes, 1530);
2148        assert_eq!(all.reclaimable_sessions, 0);
2149        assert_eq!(all.reclaimable_bytes, 0);
2150
2151        let aged = archive_space_over(root, &sessions, &BTreeMap::new(), now, Some(3));
2152        assert_eq!(aged.bytes, 1530);
2153        assert_eq!(
2154            (aged.reclaimable_sessions, aged.reclaimable_bytes),
2155            (2, 1030),
2156            "only the sessions the job would archive count, attachments included"
2157        );
2158    }
2159
2160    #[test]
2161    fn only_stopped_sessions_past_the_cut_off_are_archived() {
2162        use mj_core::state::SessionState;
2163        let selected = ready(
2164            vec![
2165                record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2166                record(
2167                    "just-stopped",
2168                    SessionState::Stopped,
2169                    "2026-09-09T00:00:00Z",
2170                ),
2171                record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
2172                record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
2173                record("unparsable", SessionState::Stopped, "not a time"),
2174                // Exactly the cut-off counts as old enough.
2175                record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
2176            ],
2177            Vec::new(),
2178        );
2179        assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
2180    }
2181
2182    #[test]
2183    fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
2184        use mj_core::state::SessionState;
2185        let selected = ready(
2186            vec![
2187                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2188                record(
2189                    "running-child",
2190                    SessionState::Running,
2191                    "2026-09-01T00:00:00Z",
2192                ),
2193            ],
2194            vec![child("running-child", "parent")],
2195        );
2196        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2197
2198        let selected = ready(
2199            vec![
2200                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2201                record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
2202            ],
2203            vec![child("young-child", "parent")],
2204        );
2205        assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2206
2207        // A child whose record is already gone holds nothing open.
2208        let selected = ready(
2209            vec![record(
2210                "parent",
2211                SessionState::Stopped,
2212                "2026-09-01T00:00:00Z",
2213            )],
2214            vec![child("departed-child", "parent")],
2215        );
2216        assert_eq!(selected, vec!["parent"]);
2217    }
2218
2219    #[test]
2220    fn children_are_archived_before_their_parents() {
2221        use mj_core::state::SessionState;
2222        let selected = ready(
2223            vec![
2224                record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2225                record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2226                record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2227            ],
2228            vec![child("child", "parent"), child("grandchild", "child")],
2229        );
2230        assert_eq!(selected, vec!["grandchild", "child", "parent"]);
2231    }
2232
2233    #[test]
2234    fn native_adapters_cover_every_enabled_profile_home() {
2235        use mj_core::config::{Config, HarnessKind, HarnessProfile};
2236
2237        fn profile(kind: HarnessKind, home: &str, enabled: bool) -> HarnessProfile {
2238            HarnessProfile {
2239                enabled,
2240                kind,
2241                home: PathBuf::from(home),
2242                environment: BTreeMap::new(),
2243                context_window_bytes: None,
2244                guardian_review_model: None,
2245            }
2246        }
2247
2248        let mut config = Config::default();
2249        for (id, built) in [
2250            (
2251                "codex",
2252                profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2253            ),
2254            (
2255                "codex-ds",
2256                profile(HarnessKind::Codex, "/home/dev/.codex-ds", true),
2257            ),
2258            // A second profile on one home must not add a second adapter.
2259            (
2260                "codex-alt",
2261                profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2262            ),
2263            (
2264                "codex-off",
2265                profile(HarnessKind::Codex, "/home/dev/.codex-off", false),
2266            ),
2267            (
2268                "claude",
2269                profile(HarnessKind::Claude, "/home/dev/.claude4", true),
2270            ),
2271            ("kimi", profile(HarnessKind::Kimi, "/home/dev/.kimi", true)),
2272            ("grok", profile(HarnessKind::Grok, "/home/dev/.grok", true)),
2273            ("muse", profile(HarnessKind::Muse, "/home/dev/muse", true)),
2274            (
2275                "muse-off",
2276                profile(HarnessKind::Muse, "/home/dev/muse-off", false),
2277            ),
2278        ] {
2279            config.profiles.insert(id.into(), built);
2280        }
2281
2282        let adapters = native_adapters(&config);
2283        let roots: Vec<(&str, Option<PathBuf>)> = adapters
2284            .iter()
2285            .map(|adapter| (adapter.name(), adapter.root()))
2286            .collect();
2287
2288        let codex: Vec<&Option<PathBuf>> = roots
2289            .iter()
2290            .filter(|(name, _)| *name == "codex")
2291            .map(|(_, root)| root)
2292            .collect();
2293        assert_eq!(
2294            codex,
2295            vec![
2296                &Some(PathBuf::from("/home/dev/.codex3/sessions")),
2297                &Some(PathBuf::from("/home/dev/.codex-ds/sessions")),
2298            ],
2299            "one adapter per enabled Codex home, deduplicated: {roots:?}"
2300        );
2301
2302        let claude: Vec<&Option<PathBuf>> = roots
2303            .iter()
2304            .filter(|(name, _)| *name == "claude-code")
2305            .map(|(_, root)| root)
2306            .collect();
2307        assert_eq!(
2308            claude,
2309            vec![&Some(PathBuf::from("/home/dev/.claude4/projects"))],
2310            "one adapter for the enabled Claude home: {roots:?}"
2311        );
2312
2313        for (_, root) in &roots {
2314            let Some(root) = root else { continue };
2315            let text = root.to_string_lossy();
2316            assert!(
2317                !text.contains(".codex-off"),
2318                "a disabled profile must not be indexed: {roots:?}"
2319            );
2320            assert!(
2321                !text.ends_with("/.codex/sessions") && !text.ends_with("/.claude/projects"),
2322                "the stock homes are not indexed unless a profile names them: {roots:?}"
2323            );
2324        }
2325
2326        // SessionWiki has no adapter for these three, so Mjolnir supplies one
2327        // per enabled profile home under its own tool name.
2328        for (name, root) in [
2329            ("kimi-code", PathBuf::from("/home/dev/.kimi/sessions")),
2330            ("grok-build", PathBuf::from("/home/dev/.grok/sessions")),
2331            (
2332                "muse",
2333                mj_checkpoint::native::muse_sessions_root(Path::new("/home/dev/muse")).unwrap(),
2334            ),
2335        ] {
2336            let found: Vec<&Option<PathBuf>> = roots
2337                .iter()
2338                .filter(|(found, _)| *found == name)
2339                .map(|(_, root)| root)
2340                .collect();
2341            assert_eq!(found, vec![&Some(root)], "one {name} adapter: {roots:?}");
2342        }
2343
2344        for (_, root) in &roots {
2345            let Some(root) = root else { continue };
2346            assert!(
2347                !root.to_string_lossy().contains("muse-off"),
2348                "a disabled profile must not be indexed: {roots:?}"
2349            );
2350        }
2351
2352        assert!(
2353            roots.iter().any(|(name, _)| *name == "gemini"),
2354            "the other built-in adapters are kept: {roots:?}"
2355        );
2356    }
2357}