1mod harness_adapters;
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::Instant;
19
20use anyhow::{Context, Result};
21use chrono::{DateTime, Utc};
22
23use mj_client::daemon::{WikiHitBlock, WikiHitTranscript, WikiIndexState, WikiRow, WikiStatus};
24use mj_core::state::{SessionRecord, State};
25use sessionwiki::adapters::{Adapter, Discovered, Store};
26use sessionwiki::model::{Message, Role, Session};
27
28use crate::controller::Controller;
29use crate::controller::checkpoint::managed_checkpoint_archive_name;
30use harness_adapters::HarnessAdapter;
31
32const TOOL: &str = "mjolnir";
36
37struct ArchiveFile {
39 path: PathBuf,
40 frontier: u64,
41 token: i64,
43}
44
45#[derive(Default)]
49struct Sessions {
50 records: BTreeMap<String, SessionRecord>,
51 subagent_ids: BTreeSet<String>,
52 live: BTreeMap<String, i64>,
54}
55
56impl Sessions {
57 fn of(state: &State) -> Self {
58 Self {
59 records: state.sessions.clone(),
60 subagent_ids: state.subagents.keys().cloned().collect(),
61 live: live_tokens(state),
62 }
63 }
64}
65
66fn live_tokens(state: &State) -> BTreeMap<String, i64> {
73 let activity = match crate::database::load_transcribed_session_activity() {
74 Ok(activity) => activity,
75 Err(error) => {
76 tracing::warn!(%error, "could not read session activity for SessionWiki");
77 return BTreeMap::new();
78 }
79 };
80 state
81 .sessions
82 .iter()
83 .filter(|(_, record)| record.state != mj_core::state::SessionState::Stopped)
84 .filter_map(|(session_id, _)| {
85 let watermark = activity.get(session_id)?;
86 Some((session_id.clone(), watermark.unwrap_or_default() / 1000))
87 })
88 .collect()
89}
90
91pub struct MjolnirAdapter {
93 sessions_dir: PathBuf,
94 sessions: std::sync::Mutex<Sessions>,
95 reload: bool,
97}
98
99impl MjolnirAdapter {
100 pub fn from_state(state: &State) -> Self {
103 Self {
104 sessions_dir: mj_core::config::sessions_dir(),
105 sessions: std::sync::Mutex::new(Sessions::of(state)),
106 reload: false,
107 }
108 }
109
110 pub fn reloading(state: &State) -> Self {
119 Self {
120 reload: true,
121 ..Self::from_state(state)
122 }
123 }
124
125 fn reload(&self) {
126 if !self.reload {
127 return;
128 }
129 match Controller::load() {
130 Ok(controller) => {
131 *self
132 .sessions
133 .lock()
134 .unwrap_or_else(std::sync::PoisonError::into_inner) =
135 Sessions::of(&controller.state)
136 }
137 Err(error) => {
138 tracing::warn!(%error, "could not refresh session records for SessionWiki")
139 }
140 }
141 }
142
143 fn checkpointed_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
146 let (newest, _) = self.newest_archives();
147 let archive = newest
148 .get(session_id)
149 .with_context(|| format!("no checkpoint archive for session {session_id}"))?;
150 let snapshot = mj_checkpoint::archive::read_archive_verified(&archive.path)
151 .with_context(|| format!("read checkpoint {}", archive.path.display()))?
152 .canonical_session()
153 .with_context(|| format!("read the transcript of session {session_id}"))?;
154 let messages = snapshot
155 .transcript
156 .iter()
157 .filter_map(|item| {
158 let (role, text) = match &item.body {
159 mj_core::archive::CanonicalTranscriptBody::User { content } => (
160 Role::User,
161 mj_core::transcript::materialized_content_text(content),
162 ),
163 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
164 Role::Assistant,
165 mj_core::transcript::materialized_chunks_text(chunks),
166 ),
167 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => {
168 (Role::Tool, tool_call_title(call))
169 }
170 _ => return None,
171 };
172 message(role, text, item.created_at_ms)
173 })
174 .collect();
175 Ok((messages, snapshot.session.session_title.clone()))
176 }
177
178 fn projected_transcript(&self, session_id: &str) -> Result<(Vec<Message>, Option<String>)> {
182 let projection = crate::database::load_materialized_session(session_id)
183 .with_context(|| format!("read the stored transcript of session {session_id}"))?
184 .with_context(|| format!("no stored transcript for session {session_id}"))?;
185 Ok((
186 projected_messages(&projection),
187 projection.session_title.clone(),
188 ))
189 }
190
191 fn key_for(&self, session_id: &str) -> String {
194 format!("{}/{session_id}", self.sessions_dir.display())
195 }
196
197 fn newest_archives(&self) -> (BTreeMap<String, ArchiveFile>, bool) {
203 let mut newest: BTreeMap<String, ArchiveFile> = BTreeMap::new();
204 let mut had_error = false;
205 let entries = match std::fs::read_dir(&self.sessions_dir) {
206 Ok(entries) => entries,
207 Err(error) => {
208 if self.sessions_dir.exists() {
209 tracing::debug!(
210 directory = %self.sessions_dir.display(),
211 %error,
212 "could not list the checkpoint directory for SessionWiki"
213 );
214 had_error = true;
215 }
216 return (newest, had_error);
217 }
218 };
219 for entry in entries {
220 let Ok(entry) = entry else {
221 had_error = true;
222 continue;
223 };
224 let Some((session_id, frontier)) = checkpoint_archive_session(&entry.file_name())
225 else {
226 continue;
227 };
228 let token = entry
229 .metadata()
230 .ok()
231 .and_then(|metadata| metadata.modified().ok())
232 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
233 .map(|age| age.as_secs() as i64)
234 .unwrap_or(0);
235 let candidate = ArchiveFile {
236 path: entry.path(),
237 frontier,
238 token,
239 };
240 match newest.get(&session_id) {
241 Some(existing) if existing.frontier >= candidate.frontier => {}
242 _ => {
243 newest.insert(session_id, candidate);
244 }
245 }
246 }
247 (newest, had_error)
248 }
249}
250
251fn checkpoint_archive_session(name: &std::ffi::OsStr) -> Option<(String, u64)> {
256 if let Some(parsed) = managed_checkpoint_archive_name(name) {
257 return Some((parsed.session_id, parsed.frontier));
258 }
259 let stem = name
260 .to_str()
261 .and_then(|name| name.strip_suffix(".hel.zip"))?;
262 mj_core::config::validate_id("session", stem)
263 .is_ok()
264 .then(|| (stem.to_owned(), 0))
265}
266
267fn projected_messages(projection: &mj_core::state::MaterializedSession) -> Vec<Message> {
269 projection
270 .transcript
271 .iter()
272 .filter_map(|item| {
273 let (role, text) = match &item.body {
274 mj_core::state::TranscriptBody::User { content } => (
275 Role::User,
276 mj_core::transcript::materialized_content_text(content),
277 ),
278 mj_core::state::TranscriptBody::Agent { chunks, .. } => (
279 Role::Assistant,
280 mj_core::transcript::materialized_chunks_text(chunks),
281 ),
282 mj_core::state::TranscriptBody::Tool { call, .. } => {
283 (Role::Tool, tool_call_title(call))
284 }
285 _ => return None,
286 };
287 message(role, text, item.created_at_ms)
288 })
289 .collect()
290}
291
292fn tool_call_title(call: &serde_json::Value) -> String {
295 call.get("title")
296 .and_then(serde_json::Value::as_str)
297 .unwrap_or_default()
298 .to_owned()
299}
300
301fn message(role: Role, text: String, created_at_ms: i64) -> Option<Message> {
303 let text = text.trim().to_owned();
304 (!text.is_empty()).then(|| Message {
305 role,
306 text,
307 ts: DateTime::from_timestamp_millis(created_at_ms),
308 })
309}
310
311fn parse_time(value: &str) -> Option<DateTime<Utc>> {
312 DateTime::parse_from_rfc3339(value)
313 .ok()
314 .map(|time| time.with_timezone(&Utc))
315}
316
317impl Adapter for MjolnirAdapter {
318 fn name(&self) -> &'static str {
319 TOOL
320 }
321
322 fn root(&self) -> Option<PathBuf> {
323 Some(self.sessions_dir.clone())
324 }
325
326 fn discover(&self) -> Discovered {
329 Discovered {
330 files: Vec::new(),
331 had_error: false,
332 }
333 }
334
335 fn parse(&self, _path: &Path) -> Result<Session> {
336 anyhow::bail!("Mjolnir sessions are parsed by key, not by file")
337 }
338
339 fn store(&self) -> Option<Store> {
340 self.reload();
341 let (newest, had_error) = self.newest_archives();
342 let mut files = Vec::with_capacity(newest.len());
343 let mut tokens: BTreeMap<String, i64> = BTreeMap::new();
344 for (session_id, archive) in newest {
345 tokens.insert(session_id, archive.token);
346 files.push(archive.path);
347 }
348 let sessions = self
353 .sessions
354 .lock()
355 .unwrap_or_else(std::sync::PoisonError::into_inner);
356 let live = sessions.live.clone();
357 tokens.extend(live);
358 for (session_id, token) in tokens.iter_mut() {
363 let updated = sessions
364 .records
365 .get(session_id)
366 .and_then(|record| parse_time(&record.updated_at))
367 .map(|updated| updated.timestamp());
368 if let Some(updated) = updated {
369 *token = (*token).max(updated);
370 }
371 }
372 let keys = tokens
373 .into_iter()
374 .map(|(session_id, token)| (self.key_for(&session_id), token))
375 .collect();
376 Some(Store {
377 keys,
378 files,
379 had_error,
380 })
381 }
382
383 fn reconcile_scope(&self) -> Option<String> {
387 Some(format!("{}/", self.sessions_dir.display()))
388 }
389
390 fn parse_key(&self, key: &str) -> Result<Session> {
391 let session_id = key.rsplit('/').next().unwrap_or_default();
392 anyhow::ensure!(!session_id.is_empty(), "no session id in key {key:?}");
393 let sessions = self
394 .sessions
395 .lock()
396 .unwrap_or_else(std::sync::PoisonError::into_inner);
397 let (messages, snapshot_title) = if sessions.live.contains_key(session_id) {
398 self.projected_transcript(session_id)?
399 } else {
400 self.checkpointed_transcript(session_id)?
401 };
402 let record = sessions.records.get(session_id);
403
404 let title = record
405 .and_then(|record| record.session_title_override.clone())
406 .or_else(|| record.and_then(|record| record.acp_session_title.clone()))
407 .or_else(|| snapshot_title.clone())
408 .unwrap_or_else(|| {
409 messages
410 .iter()
411 .find(|message| message.role == Role::User)
412 .map(|message| message.text.chars().take(80).collect())
413 .unwrap_or_default()
414 });
415
416 Ok(Session {
417 id: session_id.to_owned(),
418 tool: TOOL,
419 path: PathBuf::from(key),
420 project: record
421 .and_then(|record| record.project_directory.as_ref())
422 .map(|directory| directory.display().to_string())
423 .unwrap_or_default(),
424 started: record.and_then(|record| parse_time(&record.created_at)),
425 ended: record.and_then(|record| parse_time(&record.updated_at)),
426 title,
427 subagent: sessions.subagent_ids.contains(session_id),
428 messages,
429 touched: Vec::new(),
430 edits: Vec::new(),
431 })
432 }
433}
434
435pub struct WikiIndexer {
442 inner: Arc<Indexer>,
443}
444
445#[derive(Default)]
446struct Indexer {
447 running: tokio::sync::Mutex<()>,
449 notify: tokio::sync::Notify,
450 requested: AtomicBool,
452 full_requested: AtomicBool,
454 in_flight: AtomicBool,
457 last_success: std::sync::Mutex<Option<Success>>,
458}
459
460#[derive(Clone, Copy)]
461struct Success {
462 at: Instant,
463 epoch_seconds: i64,
464}
465
466impl WikiIndexer {
467 pub fn spawn() -> Self {
470 let inner = Arc::new(Indexer::default());
471 if let Ok(handle) = tokio::runtime::Handle::try_current() {
472 let worker = Arc::clone(&inner);
473 handle.spawn(async move { worker.run().await });
474 }
475 Self { inner }
476 }
477
478 pub fn request_sync(&self, full: bool) {
480 if full {
481 self.inner.full_requested.store(true, Ordering::Release);
482 }
483 self.inner.requested.store(true, Ordering::Release);
484 self.inner.notify.notify_one();
485 }
486
487 pub async fn sync_now(&self, full: bool) -> Result<()> {
489 self.inner.sync(full).await
490 }
491
492 pub fn status(&self) -> WikiStatus {
495 WikiStatus {
496 state: index_state(),
497 topping_up: self.inner.in_flight.load(Ordering::Acquire)
498 || self.inner.requested.load(Ordering::Acquire),
499 }
500 }
501
502 pub fn last_success(&self) -> Option<Instant> {
504 self.inner
505 .last_success
506 .lock()
507 .unwrap_or_else(std::sync::PoisonError::into_inner)
508 .map(|success| success.at)
509 }
510}
511
512impl Indexer {
513 async fn run(self: Arc<Self>) {
514 loop {
515 self.notify.notified().await;
516 while self.requested.swap(false, Ordering::AcqRel) {
517 let full = self.full_requested.swap(false, Ordering::AcqRel);
518 if let Err(error) = self.sync(full).await {
519 self.report(&error);
520 break;
525 }
526 }
527 }
528 }
529
530 fn report(&self, error: &anyhow::Error) {
534 if is_busy(error) {
535 self.requested.store(true, Ordering::Release);
536 tracing::debug!(%error, "the SessionWiki index was busy; retrying on the next trigger");
537 } else {
538 tracing::warn!(%error, "could not sync sessions into SessionWiki");
539 }
540 }
541
542 async fn sync(&self, full: bool) -> Result<()> {
543 let _guard = self.running.lock().await;
544 let since = if full {
545 None
546 } else {
547 self.last_success
548 .lock()
549 .unwrap_or_else(std::sync::PoisonError::into_inner)
550 .map(|success| success.epoch_seconds - 60)
553 };
554 let started = Instant::now();
555 self.in_flight.store(true, Ordering::Release);
556 let ran = tokio::task::spawn_blocking(move || sync_blocking(since)).await;
557 self.in_flight.store(false, Ordering::Release);
558 let ran = ran.context("run the SessionWiki sync")??;
559 if ran {
560 *self
561 .last_success
562 .lock()
563 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Success {
564 at: started,
565 epoch_seconds: Utc::now().timestamp(),
566 });
567 }
568 Ok(())
569 }
570}
571
572fn sync_blocking(since: Option<i64>) -> Result<bool> {
575 if !index_is_writable() {
576 return Ok(false);
577 }
578 let controller =
579 Controller::load().context("load controller state for the SessionWiki sync")?;
580 let mut adapters: Vec<Box<dyn sessionwiki::adapters::Adapter>> =
583 vec![Box::new(MjolnirAdapter::reloading(&controller.state))];
584 adapters.extend(native_adapters(&controller.config));
585 let mut connection = sessionwiki::index::open().context("open the SessionWiki index")?;
586 sessionwiki::index::sync_with(&mut connection, &adapters, since)
587 .context("sync the SessionWiki index")?;
588 if since.is_none() {
589 record_first_build();
593 }
594 Ok(true)
595}
596
597fn native_adapters(config: &mj_core::config::Config) -> Vec<Box<dyn Adapter>> {
614 use mj_core::config::HarnessKind;
615
616 let mut seen: BTreeSet<(HarnessKind, &Path)> = BTreeSet::new();
619 let mut adapters: Vec<Box<dyn Adapter>> = Vec::new();
620 for (_, profile) in config.enabled_profiles() {
621 if !seen.insert((profile.kind, profile.home.as_path())) {
623 continue;
624 }
625 let adapter: Box<dyn Adapter> = match profile.kind {
626 HarnessKind::Codex => {
627 Box::new(sessionwiki::adapters::Codex::in_home(profile.home.clone()))
628 }
629 HarnessKind::Claude => Box::new(sessionwiki::adapters::ClaudeCode::in_home(
630 profile.home.clone(),
631 )),
632 kind => match HarnessAdapter::in_home(kind, profile.home.clone()) {
633 Some(adapter) => Box::new(adapter),
634 None => continue,
635 },
636 };
637 adapters.push(adapter);
638 }
639 adapters.extend(
640 sessionwiki::adapters::all()
641 .into_iter()
642 .filter(|adapter| !matches!(adapter.name(), "codex" | "claude-code")),
643 );
644 adapters
645}
646
647fn index_is_isolated() -> bool {
660 static SAID: AtomicBool = AtomicBool::new(false);
661 if mj_core::config::session_index_is_resolved()
662 || std::env::var_os(mj_core::config::SESSION_INDEX_ENV).is_some()
663 {
664 return true;
665 }
666 if !SAID.swap(true, Ordering::AcqRel) {
667 tracing::debug!(
668 "this process did not resolve a session index location; SessionWiki is not used"
669 );
670 }
671 false
672}
673
674fn index_version_mismatch() -> bool {
683 static SAID: AtomicBool = AtomicBool::new(false);
684 let Ok(path) = sessionwiki::index::db_path() else {
685 return false;
686 };
687 if !path.exists() {
688 return false;
689 }
690 let version = rusqlite::Connection::open_with_flags(
691 &path,
692 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
693 )
694 .and_then(|connection| connection.pragma_query_value(None, "user_version", |row| row.get(0)));
695 let version: i64 = match version {
696 Ok(version) => version,
697 Err(error) => {
698 tracing::debug!(%error, "could not read the SessionWiki index schema version");
699 return false;
700 }
701 };
702 let mismatch = version != 0 && version != sessionwiki::index::SCHEMA_VERSION;
705 if mismatch && !SAID.swap(true, Ordering::AcqRel) {
706 tracing::warn!(
707 found = version,
708 expected = sessionwiki::index::SCHEMA_VERSION,
709 path = %path.display(),
710 "the SessionWiki index was written by another version; Mjolnir will not open it, because opening it would rebuild it. Install the matching sessionwiki command"
711 );
712 }
713 mismatch
714}
715
716fn index_is_writable() -> bool {
717 index_is_isolated() && !index_version_mismatch()
718}
719
720fn first_build_marker() -> PathBuf {
723 mj_core::config::data_dir().join("sessionwiki-built")
724}
725
726fn record_first_build() {
727 let path = first_build_marker();
728 let version = sessionwiki::index::SCHEMA_VERSION.to_string();
729 if std::fs::read_to_string(&path).is_ok_and(|held| held.trim() == version) {
730 return;
731 }
732 if let Err(error) = std::fs::write(&path, &version) {
733 tracing::warn!(%error, path = %path.display(), "could not record the first SessionWiki build");
734 }
735}
736
737fn first_build_is_done() -> bool {
739 std::fs::read_to_string(first_build_marker())
740 .is_ok_and(|held| held.trim() == sessionwiki::index::SCHEMA_VERSION.to_string())
741 && sessionwiki::index::db_path().is_ok_and(|path| path.exists())
742}
743
744pub fn index_state() -> WikiIndexState {
746 if !index_is_isolated() {
747 return WikiIndexState::Indexing;
748 }
749 if index_version_mismatch() {
750 return WikiIndexState::VersionMismatch;
751 }
752 if first_build_is_done() {
753 WikiIndexState::Ready
754 } else {
755 WikiIndexState::Indexing
756 }
757}
758
759fn is_busy(error: &anyhow::Error) -> bool {
762 error.chain().any(|cause| {
763 matches!(
764 cause.downcast_ref::<rusqlite::Error>(),
765 Some(rusqlite::Error::SqliteFailure(failure, _))
766 if matches!(
767 failure.code,
768 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
769 )
770 )
771 })
772}
773
774pub const MAX_WIKI_LIMIT: usize = 200;
780pub const DEFAULT_WIKI_LIMIT: usize = 50;
782const MIN_FULLTEXT_QUERY: usize = 3;
785pub const SYNC_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
787
788pub fn sync_is_stale(last_success: Option<Instant>) -> bool {
790 last_success.is_none_or(|at| at.elapsed() >= SYNC_STALE_AFTER)
791}
792
793pub fn query_rows(query: &str, limit: usize, live: &BTreeSet<String>) -> Result<Vec<WikiRow>> {
800 let limit = limit.clamp(1, MAX_WIKI_LIMIT);
801 if !index_is_writable() {
802 return Ok(Vec::new());
806 }
807 let connection = open_readonly()?;
808 let query = query.trim();
809 if query.is_empty() {
810 let rows = sessionwiki::index::recent(&connection, limit, None, None, None, false)
811 .context("list recent SessionWiki sessions")?;
812 return Ok(rows
813 .into_iter()
814 .map(|row| wiki_row(row, None, live))
815 .collect());
816 }
817 let hits = if query.chars().count() < MIN_FULLTEXT_QUERY {
818 sessionwiki::index::search_like(&connection, query, limit, None, None)
819 } else {
820 sessionwiki::index::search(&connection, query, limit, None, None)
821 }
822 .context("search the SessionWiki index")?;
823 let mut rows: Vec<WikiRow> = hits
824 .into_iter()
825 .map(|hit| wiki_row(hit.row, Some(hit.snippet), live))
826 .collect();
827 let found: BTreeSet<String> = rows.iter().map(|row| row.id.clone()).collect();
831 for row in named_like(&connection, query)? {
832 if rows.len() >= limit {
833 break;
834 }
835 if found.contains(&row.session_id) {
836 continue;
837 }
838 rows.push(wiki_row(row, None, live));
839 }
840 Ok(rows)
841}
842
843const NAME_SCAN_LIMIT: usize = 2_000;
847
848fn named_like(
850 connection: &rusqlite::Connection,
851 query: &str,
852) -> Result<Vec<sessionwiki::index::SessionRow>> {
853 let needle = query.to_lowercase();
854 let rows = sessionwiki::index::recent(connection, NAME_SCAN_LIMIT, None, None, None, false)
855 .context("list recent SessionWiki sessions")?;
856 Ok(rows
857 .into_iter()
858 .filter(|row| {
859 row.title.to_lowercase().contains(&needle)
860 || row.project.to_lowercase().contains(&needle)
861 })
862 .collect())
863}
864
865pub fn brief(id: &str, max_chars: usize) -> Result<Option<String>> {
867 if !index_is_writable() {
868 return Ok(None);
869 }
870 let connection = open_readonly()?;
871 let Some(row) = row_by_id(&connection, id)? else {
872 return Ok(None);
873 };
874 let session = sessionwiki::index::session_from_index(&connection, &row)
875 .context("read an indexed session")?;
876 Ok(Some(sessionwiki::commands::brief_markdown(
877 &session, max_chars, true,
878 )))
879}
880
881pub fn transcript_hits(
889 id: &str,
890 query: &str,
891 context_messages: usize,
892 per_message_chars: usize,
893) -> Result<Option<WikiHitTranscript>> {
894 if !index_is_writable() {
895 return Ok(None);
896 }
897 let connection = open_readonly()?;
898 let Some(row) = row_by_id(&connection, id)? else {
899 return Ok(None);
900 };
901 let session = sessionwiki::index::session_from_index(&connection, &row)
902 .context("read an indexed session")?;
903 Ok(Some(hit_transcript(
904 &session,
905 query,
906 context_messages,
907 per_message_chars,
908 )))
909}
910
911fn hit_transcript(
921 session: &Session,
922 query: &str,
923 context_messages: usize,
924 per_message_chars: usize,
925) -> WikiHitTranscript {
926 let needle = sessionwiki::util::nfc(query.trim()).to_lowercase();
927 if needle.is_empty() || session.messages.is_empty() {
928 return WikiHitTranscript::default();
929 }
930 let texts: Vec<String> = session
931 .messages
932 .iter()
933 .map(|message| {
934 sessionwiki::redact::redact(&sessionwiki::util::nfc(message.text.trim())).into_owned()
935 })
936 .collect();
937 let found: Vec<Vec<(usize, usize)>> = texts
943 .iter()
944 .zip(&session.messages)
945 .map(|(text, message)| match message.role {
946 Role::Tool => Vec::new(),
947 _ => matches_in(text, &needle),
948 })
949 .collect();
950
951 let last = texts.len() - 1;
954 let mut groups: Vec<(usize, usize)> = Vec::new();
955 for index in (0..texts.len()).filter(|index| !found[*index].is_empty()) {
956 let start = index.saturating_sub(context_messages);
957 let end = (index + context_messages).min(last);
958 match groups.last_mut() {
959 Some(previous) if start <= previous.1 + 1 => previous.1 = previous.1.max(end),
960 _ => groups.push((start, end)),
961 }
962 }
963 if groups.is_empty() {
964 return WikiHitTranscript::default();
965 }
966
967 let mut blocks: Vec<WikiHitBlock> = Vec::new();
968 let mut previous_end: Option<usize> = None;
969 for (start, end) in &groups {
970 let omitted = match previous_end {
971 Some(previous) => start - previous - 1,
972 None => *start,
973 };
974 for index in *start..=*end {
975 let (text, hits, truncated) = excerpt(&texts[index], &found[index], per_message_chars);
976 blocks.push(WikiHitBlock {
977 role: role_name(session.messages[index].role).to_owned(),
978 text,
979 hits,
980 omitted_before: if index == *start { omitted } else { 0 },
981 truncated,
982 });
983 }
984 previous_end = Some(*end);
985 }
986 WikiHitTranscript {
987 blocks,
988 omitted_after: last - previous_end.unwrap_or(last),
989 }
990}
991
992fn role_name(role: Role) -> &'static str {
993 match role {
994 Role::User => "user",
995 Role::Assistant => "assistant",
996 Role::Tool => "tool",
997 }
998}
999
1000fn matches_in(text: &str, needle: &str) -> Vec<(usize, usize)> {
1008 let mut lowered = String::with_capacity(text.len());
1009 let mut origin: Vec<usize> = Vec::with_capacity(text.len() + 1);
1010 for (index, character) in text.char_indices() {
1011 let before = lowered.len();
1012 lowered.extend(character.to_lowercase());
1013 origin.resize(origin.len() + (lowered.len() - before), index);
1014 }
1015 origin.push(text.len());
1016
1017 let mut hits: Vec<(usize, usize)> = Vec::new();
1018 let mut from = 0;
1019 while let Some(offset) = lowered[from..].find(needle) {
1020 let start = from + offset;
1021 from = start + needle.len();
1022 let begin = origin[start];
1023 let mut end = origin[from];
1024 if end <= begin {
1025 end = text[begin..]
1027 .chars()
1028 .next()
1029 .map_or(begin, |character| begin + character.len_utf8());
1030 }
1031 hits.push((begin, end));
1032 }
1033 hits
1034}
1035
1036fn excerpt(
1039 text: &str,
1040 hits: &[(usize, usize)],
1041 per_message_chars: usize,
1042) -> (String, Vec<(usize, usize)>, bool) {
1043 let total = text.chars().count();
1044 if per_message_chars == 0 || total <= per_message_chars {
1045 return (text.to_owned(), hits.to_vec(), false);
1046 }
1047 let first = hits
1050 .first()
1051 .map_or(0, |(start, _)| text[..*start].chars().count());
1052 let mut window_start = first.saturating_sub(per_message_chars / 4);
1053 window_start = window_start.min(total - per_message_chars);
1054 let begin = byte_of_char(text, window_start);
1055 let end = byte_of_char(text, window_start + per_message_chars);
1056 let kept = hits
1057 .iter()
1058 .filter_map(|(start, stop)| {
1059 let start = (*start).max(begin);
1060 let stop = (*stop).min(end);
1061 if start < stop {
1062 Some((start - begin, stop - begin))
1063 } else {
1064 None
1065 }
1066 })
1067 .collect();
1068 (text[begin..end].to_owned(), kept, true)
1069}
1070
1071fn byte_of_char(text: &str, char_index: usize) -> usize {
1072 text.char_indices()
1073 .nth(char_index)
1074 .map_or(text.len(), |(offset, _)| offset)
1075}
1076
1077pub struct ArchivedSession {
1081 pub title: String,
1082 pub project_directory: Option<PathBuf>,
1085 pub snapshot: mj_core::archive::CanonicalSessionSnapshot,
1086}
1087
1088pub fn archived_session(id: &str) -> Result<Option<ArchivedSession>> {
1090 if !index_is_writable() {
1091 return Ok(None);
1092 }
1093 let connection = open_readonly()?;
1094 let Some(row) = row_by_id(&connection, id)? else {
1095 return Ok(None);
1096 };
1097 let session = sessionwiki::index::session_from_index(&connection, &row)
1098 .context("read an indexed session")?;
1099 let snapshot = snapshot_of(&session)?;
1100 Ok(Some(ArchivedSession {
1101 title: session.title.clone(),
1102 project_directory: project_directory_of(&session.project),
1103 snapshot,
1104 }))
1105}
1106
1107pub fn sessions_ready_to_archive(
1123 sessions: &BTreeMap<String, SessionRecord>,
1124 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1125 now: DateTime<Utc>,
1126 older_than_days: u32,
1127) -> Vec<String> {
1128 let cutoff = now - chrono::Duration::days(i64::from(older_than_days));
1129 let aged = |session_id: &String| {
1130 sessions.get(session_id).is_some_and(|record| {
1131 record.state == mj_core::state::SessionState::Stopped
1132 && parse_time(&record.updated_at).is_some_and(|updated| updated <= cutoff)
1133 })
1134 };
1135 let selected: BTreeSet<String> = sessions
1136 .keys()
1137 .filter(|session_id| aged(session_id))
1138 .filter(|session_id| {
1139 subagents
1140 .values()
1141 .filter(|child| &&child.parent_session_id == session_id)
1142 .filter(|child| sessions.contains_key(&child.child_session_id))
1144 .all(|child| aged(&child.child_session_id))
1145 })
1146 .cloned()
1147 .collect();
1148 let mut ordered: Vec<String> = selected.iter().cloned().collect();
1149 ordered.sort_by_key(|session_id| std::cmp::Reverse(ancestor_depth(session_id, subagents)));
1150 ordered
1151}
1152
1153fn ancestor_depth(
1157 session_id: &str,
1158 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1159) -> usize {
1160 let mut depth = 0;
1161 let mut current = session_id;
1162 while let Some(parent) = subagents
1164 .get(current)
1165 .map(|child| child.parent_session_id.as_str())
1166 {
1167 depth += 1;
1168 if depth > subagents.len() {
1169 break;
1170 }
1171 current = parent;
1172 }
1173 depth
1174}
1175
1176pub use mj_core::state::ArchiveSpacePreview;
1182
1183pub fn archive_space_preview(older_than_days: Option<u32>) -> Result<ArchiveSpacePreview> {
1195 let controller =
1196 Controller::load().context("load the session records to size their storage")?;
1197 Ok(archive_space_over(
1198 &mj_core::config::sessions_dir(),
1199 &controller.state.sessions,
1200 &controller.state.subagents,
1201 Utc::now(),
1202 older_than_days,
1203 ))
1204}
1205
1206fn archive_space_over(
1209 sessions_root: &Path,
1210 sessions: &BTreeMap<String, SessionRecord>,
1211 subagents: &BTreeMap<String, mj_core::subagent::SubagentRecord>,
1212 now: DateTime<Utc>,
1213 older_than_days: Option<u32>,
1214) -> ArchiveSpacePreview {
1215 let mut preview = ArchiveSpacePreview {
1216 sessions: sessions.len(),
1217 bytes: sessions
1218 .iter()
1219 .map(|(session_id, record)| session_bytes(sessions_root, session_id, record))
1220 .sum(),
1221 reclaimable_sessions: 0,
1222 reclaimable_bytes: 0,
1223 };
1224 if let Some(days) = older_than_days {
1225 let aged = sessions_ready_to_archive(sessions, subagents, now, days);
1226 preview.reclaimable_sessions = aged.len();
1227 preview.reclaimable_bytes = aged
1228 .iter()
1229 .filter_map(|session_id| {
1230 sessions
1231 .get(session_id)
1232 .map(|record| session_bytes(sessions_root, session_id, record))
1233 })
1234 .sum();
1235 }
1236 preview
1237}
1238
1239fn session_bytes(sessions_root: &Path, session_id: &str, record: &SessionRecord) -> u64 {
1242 let checkpoint = record
1243 .checkpoint
1244 .as_ref()
1245 .and_then(|checkpoint| std::fs::metadata(&checkpoint.archive_path).ok())
1246 .filter(|metadata| metadata.is_file())
1247 .map(|metadata| metadata.len())
1248 .unwrap_or(0);
1249 let attachments = sessions_root
1250 .join(session_id)
1251 .join(mj_core::attachment::ATTACHMENT_DIR);
1252 let attachments = crate::import::claude::directory_size(&attachments).unwrap_or(0);
1253 checkpoint.saturating_add(attachments)
1254}
1255
1256pub fn indexed_with_messages(session_ids: &[String]) -> Result<BTreeSet<String>> {
1263 if !index_is_writable() {
1264 return Ok(BTreeSet::new());
1267 }
1268 let connection = open_readonly()?;
1269 let sessions_dir = mj_core::config::sessions_dir();
1270 let mut indexed = BTreeSet::new();
1271 for session_id in session_ids {
1272 let key = format!("{}/{session_id}", sessions_dir.display());
1273 let rows = sessionwiki::index::resolve(&connection, session_id)
1274 .context("look up a stopped session in the SessionWiki index")?;
1275 if rows
1276 .iter()
1277 .any(|row| row.tool == TOOL && row.path == key && row.msg_count > 0 && !row.archived)
1278 {
1279 indexed.insert(session_id.clone());
1280 }
1281 }
1282 Ok(indexed)
1283}
1284
1285fn open_readonly() -> Result<rusqlite::Connection> {
1286 sessionwiki::index::open_readonly().context("open the SessionWiki index")
1287}
1288
1289fn row_by_id(
1292 connection: &rusqlite::Connection,
1293 id: &str,
1294) -> Result<Option<sessionwiki::index::SessionRow>> {
1295 Ok(sessionwiki::index::resolve(connection, id)
1296 .context("look up an indexed session")?
1297 .into_iter()
1298 .find(|row| row.session_id == id))
1299}
1300
1301fn wiki_row(
1302 row: sessionwiki::index::SessionRow,
1303 snippet: Option<String>,
1304 live: &BTreeSet<String>,
1305) -> WikiRow {
1306 let hel_session_id = (row.tool == TOOL)
1309 .then(|| row.path.rsplit('/').next().unwrap_or_default().to_owned())
1310 .filter(|session_id| live.contains(session_id));
1311 let native_id = sessionwiki::index::native_id_of(&row.path);
1312 WikiRow {
1313 id: row.session_id,
1314 tool: row.tool,
1315 project: row.project,
1316 title: row.title,
1317 started: row.started,
1318 msgs: row.msg_count,
1319 preview: row.preview,
1320 archived: row.archived,
1321 native_id,
1322 snippet,
1323 hel_session_id,
1324 }
1325}
1326
1327fn project_directory_of(project: &str) -> Option<PathBuf> {
1335 if project.trim().is_empty() {
1336 return None;
1337 }
1338 let path = PathBuf::from(project);
1339 let repository = path
1340 .ancestors()
1341 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".mj"))
1342 .and_then(std::path::Path::parent)
1343 .map(std::path::Path::to_path_buf)
1344 .unwrap_or(path);
1345 repository.is_dir().then_some(repository)
1346}
1347
1348fn snapshot_of(
1355 session: &sessionwiki::model::Session,
1356) -> Result<mj_core::archive::CanonicalSessionSnapshot> {
1357 use mj_core::archive::{
1358 CanonicalExecutionState, CanonicalSessionSnapshot, CanonicalSessionState,
1359 CanonicalTranscriptBody, CanonicalTranscriptItem,
1360 };
1361
1362 let started_ms = session
1363 .started
1364 .map(|time| time.timestamp_millis())
1365 .unwrap_or_default();
1366 let mut transcript: Vec<CanonicalTranscriptItem> = Vec::new();
1367 for message in &session.messages {
1368 let text = message.text.trim();
1369 if text.is_empty() {
1370 continue;
1371 }
1372 if transcript.is_empty() && message.role != Role::User {
1375 continue;
1376 }
1377 let position = transcript.len() as u64 + 1;
1378 let body = match message.role {
1379 Role::User => CanonicalTranscriptBody::User {
1380 content: vec![serde_json::json!({"type": "text", "text": text})],
1381 },
1382 Role::Assistant => CanonicalTranscriptBody::Agent {
1383 chunks: vec![serde_json::json!({
1384 "content": {"type": "text", "text": text}
1385 })],
1386 streaming: false,
1387 },
1388 Role::Tool => CanonicalTranscriptBody::Tool {
1391 call: serde_json::json!({
1392 "toolCallId": format!("wiki-tool-{position}"),
1393 "title": text,
1394 "status": "completed"
1395 }),
1396 terminal_outputs: Vec::new(),
1397 terminal_refs: Vec::new(),
1398 presentation: None,
1399 },
1400 };
1401 let created_at_ms = message
1402 .ts
1403 .map(|time| time.timestamp_millis())
1404 .unwrap_or(started_ms);
1405 transcript.push(CanonicalTranscriptItem {
1406 stable_id: format!("wiki-{position}"),
1407 position,
1408 latest_content_event_ordinal: matches!(body, CanonicalTranscriptBody::Agent { .. })
1411 .then_some(position),
1412 created_at_ms,
1413 last_changed_at_ms: created_at_ms,
1414 body,
1415 });
1416 }
1417 anyhow::ensure!(
1418 !transcript.is_empty(),
1419 "the archived session has no prompt to restore from"
1420 );
1421
1422 let event_frontier = transcript.len() as u64;
1423 let last_activity_at_ms = transcript.last().map(|item| item.last_changed_at_ms);
1424 Ok(CanonicalSessionSnapshot {
1425 event_frontier,
1426 event_frontier_digest: {
1430 use sha2::Digest;
1431 mj_core::hex::lower_hex(sha2::Sha256::digest(
1432 format!("sessionwiki:{}", session.id).as_bytes(),
1433 ))
1434 },
1435 session: CanonicalSessionState {
1436 execution: CanonicalExecutionState::Idle,
1437 last_activity_at_ms,
1438 session_title: Some(session.title.clone()).filter(|title| !title.trim().is_empty()),
1439 configuration: BTreeMap::new(),
1440 },
1441 transcript,
1442 queued_prompts: Vec::new(),
1443 })
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use std::collections::BTreeMap;
1449 use std::path::Path;
1450
1451 use mj_checkpoint::archive::{
1452 ArchiveInput, BundleManifest, CanonicalExecutionState, CanonicalSessionSnapshot,
1453 CanonicalSessionState, CanonicalTranscriptBody, CanonicalTranscriptItem, SessionManifest,
1454 TargetManifest, write_archive_atomic,
1455 };
1456
1457 use super::*;
1458
1459 fn item(position: u64, body: CanonicalTranscriptBody) -> CanonicalTranscriptItem {
1460 let streamed = matches!(body, CanonicalTranscriptBody::Agent { .. });
1463 CanonicalTranscriptItem {
1464 stable_id: format!("item-{position}"),
1465 position,
1466 latest_content_event_ordinal: streamed.then_some(position),
1467 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1468 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1469 body,
1470 }
1471 }
1472
1473 fn write_archive(directory: &Path, session_id: &str, frontier: u64) {
1476 let path = directory.join(format!(
1477 "{session_id}-{frontier}-archive-{}.hel.zip",
1478 "0".repeat(32)
1479 ));
1480 write_archive_atomic(
1481 &path,
1482 &ArchiveInput {
1483 session: SessionManifest {
1484 id: session_id.into(),
1485 title: "indexed session".into(),
1486 harness_kind: mj_core::config::HarnessKind::Codex,
1487 profile_id: "codex".into(),
1488 native_session_id: "native-session".into(),
1489 created_at: "2026-09-01T00:00:00Z".into(),
1490 checkpointed_at: "2026-09-01T01:00:00Z".into(),
1491 hel_version: "test".into(),
1492 relay_version: "test".into(),
1493 adapter_version: "test".into(),
1494 },
1495 target: TargetManifest {
1496 template_id: "local".into(),
1497 target_kind: "local-bare".into(),
1498 details: BTreeMap::new(),
1499 },
1500 bundle: BundleManifest {
1501 id: "project".into(),
1502 primary_repository: "project".into(),
1503 },
1504 canonical_session: CanonicalSessionSnapshot {
1505 event_frontier: 4,
1506 event_frontier_digest: "a".repeat(64),
1507 session: CanonicalSessionState {
1508 execution: CanonicalExecutionState::Idle,
1509 last_activity_at_ms: Some(1_700_000_000_004),
1510 session_title: Some("snapshot title".into()),
1511 configuration: BTreeMap::new(),
1512 },
1513 transcript: vec![
1514 item(
1515 1,
1516 CanonicalTranscriptBody::User {
1517 content: vec![serde_json::json!({
1518 "type": "text",
1519 "text": "index this session"
1520 })],
1521 },
1522 ),
1523 item(
1524 2,
1525 CanonicalTranscriptBody::Thought {
1526 chunks: vec![serde_json::json!({
1527 "content": {"type": "text", "text": "pondering"}
1528 })],
1529 streaming: false,
1530 },
1531 ),
1532 item(
1533 3,
1534 CanonicalTranscriptBody::Tool {
1535 call: serde_json::json!({
1536 "toolCallId": "call-1",
1537 "title": "Read config.toml",
1538 "status": "completed"
1539 }),
1540 terminal_outputs: Vec::new(),
1541 terminal_refs: Vec::new(),
1542 presentation: None,
1543 },
1544 ),
1545 item(
1546 4,
1547 CanonicalTranscriptBody::Agent {
1548 chunks: vec![serde_json::json!({
1549 "content": {"type": "text", "text": "done"}
1550 })],
1551 streaming: false,
1552 },
1553 ),
1554 ],
1555 queued_prompts: Vec::new(),
1556 },
1557 native_artifacts: Vec::new(),
1558 repositories: Vec::new(),
1559 },
1560 )
1561 .unwrap();
1562 }
1563
1564 fn adapter(directory: &Path, session_id: &str) -> MjolnirAdapter {
1565 adapter_with_live(directory, session_id, BTreeMap::new())
1566 }
1567
1568 fn adapter_with_live(
1569 directory: &Path,
1570 session_id: &str,
1571 live: BTreeMap<String, i64>,
1572 ) -> MjolnirAdapter {
1573 let record = SessionRecord {
1574 id: session_id.into(),
1575 ..record_template()
1576 };
1577 MjolnirAdapter {
1578 sessions_dir: directory.to_path_buf(),
1579 sessions: std::sync::Mutex::new(Sessions {
1580 records: BTreeMap::from([(session_id.to_owned(), record)]),
1581 subagent_ids: BTreeSet::new(),
1582 live,
1583 }),
1584 reload: false,
1585 }
1586 }
1587
1588 fn record_template() -> SessionRecord {
1589 SessionRecord {
1590 build_cache: None,
1591 container_workspace: None,
1592 mjolnir_subagents: None,
1593 create_managed_worktree: None,
1594 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
1595 archived: false,
1596 container_cpus: None,
1597 container_memory: None,
1598 id: "0123456789abcdef0123456789abcdef".into(),
1599 title: "indexed session".into(),
1600 harness_kind: mj_core::config::HarnessKind::Codex,
1601 last_profile: "codex".into(),
1602 bundle_id: "project".into(),
1603 project_directory: Some(PathBuf::from("/home/dev/project")),
1604 managed_worktree: None,
1605 target_template_id: "local-bare".into(),
1606 resource_allocation: None,
1607 additional_mounts: Vec::new(),
1608 state: mj_core::state::SessionState::Stopped,
1609 target: None,
1610 native_session_id: Some("native-session".into()),
1611 acp_session_title: Some("the harness title".into()),
1612 session_title_override: None,
1613 created_at: "2026-09-01T00:00:00Z".into(),
1614 updated_at: "2026-09-01T01:00:00Z".into(),
1615 viewed_through_event_ordinal: 0,
1616 draft_input: String::new(),
1617 last_error: None,
1618 last_checkpoint_error: None,
1619 checkpoint: None,
1620 }
1621 }
1622
1623 #[test]
1624 fn the_newest_checkpoint_of_each_session_is_one_indexed_key() {
1625 let directory = tempfile::tempdir().unwrap();
1626 let session_id = "0123456789abcdef0123456789abcdef";
1627 write_archive(directory.path(), session_id, 1);
1628 write_archive(directory.path(), session_id, 7);
1629 let adapter = adapter(directory.path(), session_id);
1630
1631 let store = adapter.store().expect("the adapter is a shared store");
1632 let key = format!("{}/{session_id}", directory.path().display());
1633 assert_eq!(
1634 store
1635 .keys
1636 .iter()
1637 .map(|(key, _)| key.as_str())
1638 .collect::<Vec<_>>(),
1639 vec![key.as_str()]
1640 );
1641 assert!(!store.had_error);
1642 assert_eq!(store.files.len(), 1);
1643 assert!(
1644 store.files[0]
1645 .file_name()
1646 .unwrap()
1647 .to_str()
1648 .unwrap()
1649 .contains("-7-archive-"),
1650 "the newest checkpoint is the one indexed: {:?}",
1651 store.files[0]
1652 );
1653 assert_eq!(
1654 adapter.reconcile_scope(),
1655 Some(format!("{}/", directory.path().display()))
1656 );
1657
1658 let session = adapter.parse_key(&key).unwrap();
1659 assert_eq!(session.id, session_id);
1660 assert_eq!(session.tool, "mjolnir");
1661 assert_eq!(session.path, PathBuf::from(&key));
1662 assert_eq!(session.project, "/home/dev/project");
1663 assert_eq!(session.title, "the harness title");
1664 assert!(!session.subagent);
1665 assert_eq!(
1666 session
1667 .messages
1668 .iter()
1669 .map(|message| (message.role, message.text.as_str()))
1670 .collect::<Vec<_>>(),
1671 vec![
1672 (Role::User, "index this session"),
1673 (Role::Tool, "Read config.toml"),
1674 (Role::Assistant, "done"),
1675 ]
1676 );
1677 }
1678
1679 fn projection(session_id: &str) -> mj_core::state::MaterializedSession {
1680 use mj_core::transcript::{TranscriptBody, TranscriptItem};
1681 let mut projected = mj_core::state::MaterializedSession::empty(session_id);
1682 let mut push = |position: u64, body: TranscriptBody| {
1683 let streamed = matches!(body, TranscriptBody::Agent { .. });
1684 projected
1685 .transcript
1686 .push(std::sync::Arc::new(TranscriptItem {
1687 stable_id: format!("item-{position}"),
1688 position,
1689 latest_content_event_ordinal: streamed.then_some(position),
1690 created_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1691 last_changed_at_ms: 1_700_000_000_000 + i64::try_from(position).unwrap(),
1692 body,
1693 }));
1694 };
1695 push(
1696 1,
1697 TranscriptBody::User {
1698 content: vec![serde_json::json!({"type": "text", "text": "still talking"})],
1699 },
1700 );
1701 push(
1702 2,
1703 TranscriptBody::Thought {
1704 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "hmm"}})],
1705 streaming: false,
1706 },
1707 );
1708 push(
1709 3,
1710 TranscriptBody::Tool {
1711 call: serde_json::json!({"toolCallId": "c1", "title": "Read README.md"}),
1712 terminal_outputs: Vec::new(),
1713 terminal_refs: Vec::new(),
1714 presentation: None,
1715 },
1716 );
1717 push(
1718 4,
1719 TranscriptBody::Agent {
1720 chunks: vec![serde_json::json!({"content": {"type": "text", "text": "reading"}})],
1721 streaming: false,
1722 },
1723 );
1724 projected.session_title = Some("the live title".into());
1725 projected
1726 }
1727
1728 #[test]
1731 fn a_running_session_is_indexed_from_its_stored_transcript() {
1732 let session_id = "0123456789abcdef0123456789abcdef";
1733 assert_eq!(
1734 projected_messages(&projection(session_id))
1735 .iter()
1736 .map(|message| (message.role, message.text.clone()))
1737 .collect::<Vec<_>>(),
1738 vec![
1739 (Role::User, "still talking".to_owned()),
1740 (Role::Tool, "Read README.md".to_owned()),
1741 (Role::Assistant, "reading".to_owned()),
1742 ],
1743 "a thought is skipped and every other item keeps its role"
1744 );
1745 }
1746
1747 #[test]
1752 fn a_running_session_is_listed_with_its_own_change_token() {
1753 let directory = tempfile::tempdir().unwrap();
1754 let running = "0123456789abcdef0123456789abcdef";
1755 let never_checkpointed = "fedcba9876543210fedcba9876543210";
1756 write_archive(directory.path(), running, 3);
1757 let live = adapter_with_live(
1758 directory.path(),
1759 running,
1760 BTreeMap::from([
1761 (running.to_owned(), 1_900_000_000),
1762 (never_checkpointed.to_owned(), 1_900_000_001),
1763 ]),
1764 );
1765
1766 let store = live.store().expect("the adapter is a shared store");
1767 let key_of = |session_id: &str| format!("{}/{session_id}", directory.path().display());
1768 assert_eq!(
1769 store.keys,
1770 vec![
1771 (key_of(running), 1_900_000_000),
1772 (key_of(never_checkpointed), 1_900_000_001),
1773 ],
1774 "a live session's own token replaces the checkpoint's"
1775 );
1776
1777 let stopped = adapter(directory.path(), running);
1780 let keys = stopped.store().expect("a shared store").keys;
1781 assert_eq!(keys.len(), 1);
1782 assert_eq!(keys[0].0, key_of(running));
1783 assert_ne!(keys[0].1, 1_900_000_000);
1784 assert_eq!(
1785 stopped.parse_key(&key_of(running)).unwrap().title,
1786 "the harness title",
1787 "a stopped session is parsed from its checkpoint"
1788 );
1789 }
1790
1791 #[test]
1794 fn a_rename_moves_a_session_change_token() {
1795 let directory = tempfile::tempdir().unwrap();
1796 let session_id = "0123456789abcdef0123456789abcdef";
1797 write_archive(directory.path(), session_id, 1);
1798 let adapter = adapter(directory.path(), session_id);
1799 let before = adapter.store().expect("a shared store").keys[0].1;
1800
1801 {
1802 let mut sessions = adapter.sessions.lock().unwrap();
1803 let record = sessions.records.get_mut(session_id).unwrap();
1804 record.session_title_override = Some("the new name".into());
1805 record.updated_at = "2099-01-01T00:00:00Z".into();
1806 }
1807 let after = adapter.store().expect("a shared store").keys[0].1;
1808 assert!(
1809 after > before,
1810 "a renamed session is re-indexed: {before} then {after}"
1811 );
1812 assert_eq!(
1813 adapter
1814 .parse_key(&format!("{}/{session_id}", directory.path().display()))
1815 .unwrap()
1816 .title,
1817 "the new name"
1818 );
1819 }
1820
1821 fn indexed(messages: Vec<(Role, &str)>) -> sessionwiki::model::Session {
1822 Session {
1823 id: "0123456789abcdef0123456789abcdef".into(),
1824 tool: "mjolnir",
1825 path: PathBuf::from("/sessions/0123456789abcdef0123456789abcdef"),
1826 project: "/home/dev/project".into(),
1827 started: DateTime::from_timestamp_millis(1_700_000_000_000),
1828 ended: None,
1829 title: "the archived session".into(),
1830 subagent: false,
1831 messages: messages
1832 .into_iter()
1833 .map(|(role, text)| Message {
1834 role,
1835 text: text.to_owned(),
1836 ts: None,
1837 })
1838 .collect(),
1839 touched: Vec::new(),
1840 edits: Vec::new(),
1841 }
1842 }
1843
1844 #[test]
1847 fn transcript_hits_locates_case_insensitive_matches() {
1848 let session = indexed(vec![
1849 (Role::User, "Make the Tests green"),
1850 (Role::Assistant, "the tests are green now"),
1851 ]);
1852
1853 let found = hit_transcript(&session, "TESTS", 0, 4_000);
1854
1855 assert_eq!(found.blocks.len(), 2, "both messages contain the query");
1856 assert_eq!(found.blocks[0].role, "user");
1857 let (start, end) = found.blocks[0].hits[0];
1858 assert_eq!(&found.blocks[0].text[start..end], "Tests");
1859 let (start, end) = found.blocks[1].hits[0];
1860 assert_eq!(&found.blocks[1].text[start..end], "tests");
1861 assert!(!found.blocks[0].truncated);
1862 assert_eq!(found.omitted_after, 0);
1863 }
1864
1865 #[test]
1868 fn transcript_hits_keeps_context_and_marks_omissions() {
1869 let session = indexed(vec![
1870 (Role::User, "zero"),
1871 (Role::Assistant, "one needle one"),
1872 (Role::Tool, "two"),
1873 (Role::User, "three"),
1874 (Role::Assistant, "four"),
1875 (Role::Tool, "five"),
1876 (Role::User, "six needle six"),
1877 (Role::Assistant, "seven"),
1878 (Role::User, "eight"),
1879 ]);
1880
1881 let found = hit_transcript(&session, "needle", 1, 4_000);
1882
1883 let shown: Vec<(&str, &str, usize)> = found
1884 .blocks
1885 .iter()
1886 .map(|block| {
1887 (
1888 block.role.as_str(),
1889 block.text.as_str(),
1890 block.omitted_before,
1891 )
1892 })
1893 .collect();
1894 assert_eq!(
1895 shown,
1896 vec![
1897 ("user", "zero", 0),
1898 ("assistant", "one needle one", 0),
1899 ("tool", "two", 0),
1900 ("tool", "five", 2),
1901 ("user", "six needle six", 0),
1902 ("assistant", "seven", 0),
1903 ]
1904 );
1905 assert_eq!(found.omitted_after, 1, "the last message is not shown");
1906 assert!(found.blocks[0].hits.is_empty(), "context has no hits");
1907 }
1908
1909 #[test]
1915 fn transcript_hits_never_anchor_on_tool_output() {
1916 let session = indexed(vec![
1917 (Role::User, "make it build"),
1918 (Role::Tool, "cargo build --needle"),
1919 (Role::Assistant, "it builds"),
1920 ]);
1921
1922 let only_in_a_tool = hit_transcript(&session, "needle", 1, 4_000);
1923 assert!(
1924 only_in_a_tool.blocks.is_empty(),
1925 "tool output must not anchor a passage, got {:?}",
1926 only_in_a_tool.blocks
1927 );
1928
1929 let beside_a_match = hit_transcript(&session, "builds", 1, 4_000);
1930 let shown: Vec<(&str, bool)> = beside_a_match
1931 .blocks
1932 .iter()
1933 .map(|block| (block.role.as_str(), !block.hits.is_empty()))
1934 .collect();
1935 assert_eq!(
1936 shown,
1937 vec![("tool", false), ("assistant", true)],
1938 "a tool message is still context around a real match"
1939 );
1940 }
1941
1942 #[test]
1945 fn transcript_hits_window_keeps_the_first_hit() {
1946 let filler = "x".repeat(4_000);
1947 let session = indexed(vec![(Role::User, &format!("{filler} needle {filler}"))]);
1948
1949 let found = hit_transcript(&session, "needle", 0, 100);
1950
1951 let block = &found.blocks[0];
1952 assert!(block.truncated);
1953 assert_eq!(block.text.chars().count(), 100);
1954 assert_eq!(block.hits.len(), 1, "the windowed text keeps its hit");
1955 let (start, end) = block.hits[0];
1956 assert_eq!(&block.text[start..end], "needle");
1957 assert!(
1958 start >= 20,
1959 "the window keeps lead-in before the hit, got {start}"
1960 );
1961 }
1962
1963 #[test]
1967 fn a_restored_snapshot_is_a_valid_transcript_of_the_indexed_session() {
1968 let snapshot = snapshot_of(&indexed(vec![
1969 (Role::User, "make the tests green"),
1970 (Role::Tool, "Read src/lib.rs"),
1971 (Role::Assistant, "they are green now"),
1972 (Role::User, " "),
1973 ]))
1974 .unwrap();
1975
1976 snapshot.validate().expect("the snapshot is well formed");
1977 assert_eq!(snapshot.event_frontier, 3);
1978 assert_eq!(
1979 snapshot.session.session_title.as_deref(),
1980 Some("the archived session")
1981 );
1982 assert!(snapshot.session.last_activity_at_ms.is_some());
1983 let bodies = snapshot
1984 .transcript
1985 .iter()
1986 .map(|item| match &item.body {
1987 mj_core::archive::CanonicalTranscriptBody::User { content } => (
1988 "user",
1989 mj_core::transcript::materialized_content_text(content),
1990 ),
1991 mj_core::archive::CanonicalTranscriptBody::Agent { chunks, .. } => (
1992 "agent",
1993 mj_core::transcript::materialized_chunks_text(chunks),
1994 ),
1995 mj_core::archive::CanonicalTranscriptBody::Tool { call, .. } => (
1996 "tool",
1997 call["title"].as_str().unwrap_or_default().to_owned(),
1998 ),
1999 _ => ("other", String::new()),
2000 })
2001 .collect::<Vec<_>>();
2002 assert_eq!(
2003 bodies,
2004 vec![
2005 ("user", "make the tests green".to_owned()),
2006 ("tool", "Read src/lib.rs".to_owned()),
2007 ("agent", "they are green now".to_owned()),
2008 ],
2009 "the blank message is dropped and every other one keeps its role"
2010 );
2011 }
2012
2013 #[test]
2017 fn messages_before_the_first_prompt_are_dropped() {
2018 let snapshot = snapshot_of(&indexed(vec![
2019 (Role::Assistant, "still working"),
2020 (Role::User, "carry on"),
2021 ]))
2022 .unwrap();
2023 assert_eq!(snapshot.transcript.len(), 1);
2024 assert_eq!(snapshot.transcript[0].position, 1);
2025 snapshot.validate().unwrap();
2026
2027 let error = snapshot_of(&indexed(vec![(Role::Assistant, "nobody asked")])).unwrap_err();
2028 assert!(
2029 error.to_string().contains("no prompt"),
2030 "a session with no prompt cannot be restored: {error}"
2031 );
2032 }
2033
2034 fn record(
2035 session_id: &str,
2036 state: mj_core::state::SessionState,
2037 updated_at: &str,
2038 ) -> SessionRecord {
2039 SessionRecord {
2040 id: session_id.into(),
2041 state,
2042 updated_at: updated_at.into(),
2043 ..record_template()
2044 }
2045 }
2046
2047 fn child(child_session_id: &str, parent_session_id: &str) -> mj_core::subagent::SubagentRecord {
2048 mj_core::subagent::SubagentRecord {
2049 child_session_id: child_session_id.into(),
2050 parent_session_id: parent_session_id.into(),
2051 task_name: "task".into(),
2052 profile_id: "codex".into(),
2053 model: None,
2054 effort: None,
2055 working_directory: PathBuf::new(),
2056 initial_prompt: "do the thing".into(),
2057 request_key: "key".into(),
2058 created_at: "2026-09-01T00:00:00Z".into(),
2059 noticed_turn: None,
2060 }
2061 }
2062
2063 fn ready(
2064 sessions: Vec<SessionRecord>,
2065 children: Vec<mj_core::subagent::SubagentRecord>,
2066 ) -> Vec<String> {
2067 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2068 sessions_ready_to_archive(
2069 &sessions
2070 .into_iter()
2071 .map(|record| (record.id.clone(), record))
2072 .collect(),
2073 &children
2074 .into_iter()
2075 .map(|child| (child.child_session_id.clone(), child))
2076 .collect(),
2077 now,
2078 3,
2079 )
2080 }
2081
2082 fn sized_session(
2084 root: &Path,
2085 session_id: &str,
2086 updated_at: &str,
2087 checkpoint_bytes: usize,
2088 attachment_bytes: &[usize],
2089 ) -> SessionRecord {
2090 let archive_path = root.join(format!("{session_id}.hel.zip"));
2091 std::fs::write(&archive_path, vec![b'c'; checkpoint_bytes]).unwrap();
2092 if !attachment_bytes.is_empty() {
2093 let attachments = root
2094 .join(session_id)
2095 .join(mj_core::attachment::ATTACHMENT_DIR);
2096 std::fs::create_dir_all(&attachments).unwrap();
2097 for (index, size) in attachment_bytes.iter().enumerate() {
2098 std::fs::write(attachments.join(format!("{index}.png")), vec![b'a'; *size])
2099 .unwrap();
2100 }
2101 }
2102 SessionRecord {
2103 checkpoint: Some(mj_core::state::CheckpointMetadata {
2104 archive_path,
2105 sha256: "0".repeat(64),
2106 created_at: updated_at.into(),
2107 event_frontier: 1,
2108 }),
2109 ..record(
2110 session_id,
2111 mj_core::state::SessionState::Stopped,
2112 updated_at,
2113 )
2114 }
2115 }
2116
2117 #[test]
2118 fn the_space_preview_sizes_every_session_and_only_the_aged_ones_as_reclaimable() {
2119 let directory = tempfile::tempdir().unwrap();
2120 let root = directory.path();
2121 let sessions: BTreeMap<String, SessionRecord> = [
2122 sized_session(root, "old-stopped", "2026-09-01T00:00:00Z", 1000, &[10, 20]),
2123 sized_session(root, "just-stopped", "2026-09-09T00:00:00Z", 500, &[]),
2124 SessionRecord {
2127 checkpoint: Some(mj_core::state::CheckpointMetadata {
2128 archive_path: root.join("missing.hel.zip"),
2129 sha256: "0".repeat(64),
2130 created_at: "2026-09-01T00:00:00Z".into(),
2131 event_frontier: 1,
2132 }),
2133 ..record(
2134 "lost-checkpoint",
2135 mj_core::state::SessionState::Stopped,
2136 "2026-09-01T00:00:00Z",
2137 )
2138 },
2139 ]
2140 .into_iter()
2141 .map(|record| (record.id.clone(), record))
2142 .collect();
2143 let now = parse_time("2026-09-10T00:00:00Z").unwrap();
2144
2145 let all = archive_space_over(root, &sessions, &BTreeMap::new(), now, None);
2146 assert_eq!(all.sessions, 3);
2147 assert_eq!(all.bytes, 1530);
2148 assert_eq!(all.reclaimable_sessions, 0);
2149 assert_eq!(all.reclaimable_bytes, 0);
2150
2151 let aged = archive_space_over(root, &sessions, &BTreeMap::new(), now, Some(3));
2152 assert_eq!(aged.bytes, 1530);
2153 assert_eq!(
2154 (aged.reclaimable_sessions, aged.reclaimable_bytes),
2155 (2, 1030),
2156 "only the sessions the job would archive count, attachments included"
2157 );
2158 }
2159
2160 #[test]
2161 fn only_stopped_sessions_past_the_cut_off_are_archived() {
2162 use mj_core::state::SessionState;
2163 let selected = ready(
2164 vec![
2165 record("old-stopped", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2166 record(
2167 "just-stopped",
2168 SessionState::Stopped,
2169 "2026-09-09T00:00:00Z",
2170 ),
2171 record("old-running", SessionState::Running, "2026-09-01T00:00:00Z"),
2172 record("old-error", SessionState::Error, "2026-09-01T00:00:00Z"),
2173 record("unparsable", SessionState::Stopped, "not a time"),
2174 record("at-the-edge", SessionState::Stopped, "2026-09-07T00:00:00Z"),
2176 ],
2177 Vec::new(),
2178 );
2179 assert_eq!(selected, vec!["at-the-edge", "old-stopped"]);
2180 }
2181
2182 #[test]
2183 fn a_child_the_pass_is_not_archiving_holds_its_parent_back() {
2184 use mj_core::state::SessionState;
2185 let selected = ready(
2186 vec![
2187 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2188 record(
2189 "running-child",
2190 SessionState::Running,
2191 "2026-09-01T00:00:00Z",
2192 ),
2193 ],
2194 vec![child("running-child", "parent")],
2195 );
2196 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2197
2198 let selected = ready(
2199 vec![
2200 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2201 record("young-child", SessionState::Stopped, "2026-09-09T00:00:00Z"),
2202 ],
2203 vec![child("young-child", "parent")],
2204 );
2205 assert!(selected.is_empty(), "the parent must wait: {selected:?}");
2206
2207 let selected = ready(
2209 vec![record(
2210 "parent",
2211 SessionState::Stopped,
2212 "2026-09-01T00:00:00Z",
2213 )],
2214 vec![child("departed-child", "parent")],
2215 );
2216 assert_eq!(selected, vec!["parent"]);
2217 }
2218
2219 #[test]
2220 fn children_are_archived_before_their_parents() {
2221 use mj_core::state::SessionState;
2222 let selected = ready(
2223 vec![
2224 record("parent", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2225 record("child", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2226 record("grandchild", SessionState::Stopped, "2026-09-01T00:00:00Z"),
2227 ],
2228 vec![child("child", "parent"), child("grandchild", "child")],
2229 );
2230 assert_eq!(selected, vec!["grandchild", "child", "parent"]);
2231 }
2232
2233 #[test]
2234 fn native_adapters_cover_every_enabled_profile_home() {
2235 use mj_core::config::{Config, HarnessKind, HarnessProfile};
2236
2237 fn profile(kind: HarnessKind, home: &str, enabled: bool) -> HarnessProfile {
2238 HarnessProfile {
2239 enabled,
2240 kind,
2241 home: PathBuf::from(home),
2242 environment: BTreeMap::new(),
2243 context_window_bytes: None,
2244 guardian_review_model: None,
2245 }
2246 }
2247
2248 let mut config = Config::default();
2249 for (id, built) in [
2250 (
2251 "codex",
2252 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2253 ),
2254 (
2255 "codex-ds",
2256 profile(HarnessKind::Codex, "/home/dev/.codex-ds", true),
2257 ),
2258 (
2260 "codex-alt",
2261 profile(HarnessKind::Codex, "/home/dev/.codex3", true),
2262 ),
2263 (
2264 "codex-off",
2265 profile(HarnessKind::Codex, "/home/dev/.codex-off", false),
2266 ),
2267 (
2268 "claude",
2269 profile(HarnessKind::Claude, "/home/dev/.claude4", true),
2270 ),
2271 ("kimi", profile(HarnessKind::Kimi, "/home/dev/.kimi", true)),
2272 ("grok", profile(HarnessKind::Grok, "/home/dev/.grok", true)),
2273 ("muse", profile(HarnessKind::Muse, "/home/dev/muse", true)),
2274 (
2275 "muse-off",
2276 profile(HarnessKind::Muse, "/home/dev/muse-off", false),
2277 ),
2278 ] {
2279 config.profiles.insert(id.into(), built);
2280 }
2281
2282 let adapters = native_adapters(&config);
2283 let roots: Vec<(&str, Option<PathBuf>)> = adapters
2284 .iter()
2285 .map(|adapter| (adapter.name(), adapter.root()))
2286 .collect();
2287
2288 let codex: Vec<&Option<PathBuf>> = roots
2289 .iter()
2290 .filter(|(name, _)| *name == "codex")
2291 .map(|(_, root)| root)
2292 .collect();
2293 assert_eq!(
2294 codex,
2295 vec![
2296 &Some(PathBuf::from("/home/dev/.codex3/sessions")),
2297 &Some(PathBuf::from("/home/dev/.codex-ds/sessions")),
2298 ],
2299 "one adapter per enabled Codex home, deduplicated: {roots:?}"
2300 );
2301
2302 let claude: Vec<&Option<PathBuf>> = roots
2303 .iter()
2304 .filter(|(name, _)| *name == "claude-code")
2305 .map(|(_, root)| root)
2306 .collect();
2307 assert_eq!(
2308 claude,
2309 vec![&Some(PathBuf::from("/home/dev/.claude4/projects"))],
2310 "one adapter for the enabled Claude home: {roots:?}"
2311 );
2312
2313 for (_, root) in &roots {
2314 let Some(root) = root else { continue };
2315 let text = root.to_string_lossy();
2316 assert!(
2317 !text.contains(".codex-off"),
2318 "a disabled profile must not be indexed: {roots:?}"
2319 );
2320 assert!(
2321 !text.ends_with("/.codex/sessions") && !text.ends_with("/.claude/projects"),
2322 "the stock homes are not indexed unless a profile names them: {roots:?}"
2323 );
2324 }
2325
2326 for (name, root) in [
2329 ("kimi-code", PathBuf::from("/home/dev/.kimi/sessions")),
2330 ("grok-build", PathBuf::from("/home/dev/.grok/sessions")),
2331 (
2332 "muse",
2333 mj_checkpoint::native::muse_sessions_root(Path::new("/home/dev/muse")).unwrap(),
2334 ),
2335 ] {
2336 let found: Vec<&Option<PathBuf>> = roots
2337 .iter()
2338 .filter(|(found, _)| *found == name)
2339 .map(|(_, root)| root)
2340 .collect();
2341 assert_eq!(found, vec![&Some(root)], "one {name} adapter: {roots:?}");
2342 }
2343
2344 for (_, root) in &roots {
2345 let Some(root) = root else { continue };
2346 assert!(
2347 !root.to_string_lossy().contains("muse-off"),
2348 "a disabled profile must not be indexed: {roots:?}"
2349 );
2350 }
2351
2352 assert!(
2353 roots.iter().any(|(name, _)| *name == "gemini"),
2354 "the other built-in adapters are kept: {roots:?}"
2355 );
2356 }
2357}