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