diff --git a/AGENTS.md b/AGENTS.md index de9afda..70aaf9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ own mapping as an extension in its own module. │ │ │ ├── SessionModels.swift # SessionProvider, SessionSummary, transcripts, delete plans │ │ │ ├── SessionParsing.swift # Provider-agnostic, total parsing primitives │ │ │ ├── SessionProviderAdapter.swift # The protocol + the registry -│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse,Devin,MistralVibe}SessionAdapter.swift +│ │ │ ├── {Claude,ClaudeCowork,Codex,Grok,Cursor,Gemini,Antigravity,GrokBot,Muse,Devin,MistralVibe,MuseAgent}SessionAdapter.swift │ │ │ ├── CodexTitleHydrator.swift # Titles from Codex's side index │ │ │ ├── AntigravityConversationIndex.swift │ │ │ ├── LiveSQLiteReader.swift # Read a store another process holds open @@ -165,7 +165,10 @@ sockets inside the session directory. Devin keeps every session as rows in one database the CLI holds open, so a delete would be SQL against another process's live store — that is `devin rm`'s job. Mistral Vibe caches its listing, leases running sessions, nests sub-agent sessions inside the parent's -directory, and sweeps worktrees no listed session resumes into. Removing from +directory, and sweeps worktrees no listed session resumes into. Muse (the +desktop app, not Muse Code) is Grok Bot's case again: its conversation cache +is owned and rewritten by the running app, and the conversations themselves +live on Meta's servers. Removing from underneath a live SQLite handle is how a store gets corrupted rather than emptied. Do not "fix" that. diff --git a/CHANGELOG.md b/CHANGELOG.md index 40e71f1..9216e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.11.0] - 2026-09-23 + +Meta's Muse app (muse.ai's Mac client) joins the session list as a read-only +provider distinct from Muse Code. New provider and harness raw value +(`museAgent`) lands in host caches. + +### Added + +- **`MuseAgentSessionAdapter`** — Meta's desktop agent app, `Muse.app` + (muse.ai's Mac client, bundle `com.meta.endo`), is a listed, readable, + searchable provider: **`SessionProvider.museAgent`** / **`Harness.museAgent`** + (raw value `museAgent`, display name "Muse", `HarnessCatalog.museAgent`). It + is not Muse Code — the `muse` CLI keeps its own provider and harness. The app + caches each conversation at `~/Library/Caches/ConversationCache/` as one JSON + array of messages (`storeRelativePath`); the directory is shared with the + consumer `Meta AI.app`, so discovery lists only `hatch-.json` files, flat, + without following links, and never the UUID-named Meta AI chats beside them. + The session id is the file stem (`hatch-main`). Messages are ordered by + `sortSeq`; roles come from `isUser`; text is `content`, or the message's + markdown blocks when that is empty. Timestamps are seconds since the Apple + reference date and are converted as such. An `isUser` that is not a JSON + boolean marks an entry as foreign and it is skipped. The title is the first prompt, + since the cache has none. Read-only (`supportsDeletion == false`), no resume + command, and no model, token counts, or project directory: the agent runs in + a VM on Meta's servers and the cache may hold only part of the history. +- The Rust lane knows the provider: `SessionProvider::MuseAgent` (raw value + `museAgent`, default harness "Muse"), read-only, no resume, and the index + reader's harness filter accepts "Muse" and `museAgent`. + ## [0.10.0] - 2026-09-17 Cognition's Devin and Mistral AI's Mistral Vibe join the session list. Devin diff --git a/Cargo.lock b/Cargo.lock index 336545c..2800315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agent-session-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "rusqlite", "serde", diff --git a/README.md b/README.md index 23ece26..4feb7b3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ point takes the home directory it should read. | Muse Code | `~/.local/share/muse/sessions/YYYY/MM/DD//session.jsonl` | ✅ | ✅ | ❌ the CLI indexes and locks it | | Devin | `~/.local/share/devin/cli/sessions.db` — one database, a row per session | ✅ | ✅ | ❌ rows in a live database | | Mistral Vibe | `~/.vibe/logs/session/session_*/{meta.json,messages.jsonl}` | ✅ | ✅ | ❌ the CLI indexes and leases it | +| Muse | `~/Library/Caches/ConversationCache/hatch-*.json` | ⚠️ only what the client cached | ❌ the run happens server-side | ❌ a cloud cache | Where a ⚠️ appears the log genuinely does not carry the value — an aborted Cursor conversation records no model name at all, and old Gemini CLI chats @@ -115,8 +116,19 @@ an empty one. Grok Bot's ❌ is the stronger statement: the conversation runs on xAI's servers and the client's cache records no model, no token counts, and no cost for anyone to read. -Seven providers are listed and readable but never deletable, because another -running app — or, for Grok Bot, a server — owns the store. +Muse — Meta's desktop agent app, `Muse.app`, not the `muse` CLI that is Muse +Code — is the same kind of store. Its agent runs in a VM on Meta's servers, and +the app caches each conversation as one JSON array of messages. The cache +holds no model, no token counts, no title (the list row's title is the first +prompt), and no local project directory, and it may hold only part of the +history; the running app rewrites it at will. Its timestamps are seconds since +2001-01-01 (Apple's reference date), not Unix seconds. The directory is shared +with the consumer `Meta AI.app`, which is built from the same code: only the +`hatch-*.json` files are Muse's, and the UUID-named ones beside them are never +listed. + +Eight providers are listed and readable but never deletable, because another +running app — or, for Grok Bot and Muse, a server — owns the store. `SessionProvider.supportsDeletion` says so up front, and the adapters fail closed with `SessionDeleteError.providerIsReadOnly`. @@ -307,6 +319,7 @@ snapshot.quota?.usedPercent // 43.2, from Codex's own rate_limits | Grok Build | `signals.json`: `contextTokensUsed` and `contextWindowTokens` | `measured` | | Cursor, AntiGravity, Grok Bot, Gemini CLI, Muse Code | nothing on disk answers it | `nil` | | Devin, Mistral Vibe | no live adapter yet; Devin records per-reply `metrics`, Vibe `stats.context_tokens` | `nil` | +| Muse | nothing on disk answers it; the run happens server-side | `nil` | Claude Code computes both its window size *and* its category breakdown (messages, system tools, skills, MCP tools, memory files) in-process and writes @@ -360,6 +373,7 @@ table goes through `ArgvSanitizer`. | Muse Code | — | `~/.local/share/muse/sessions/**/session.jsonl` | | Devin | — | `~/.local/share/devin/cli/sessions.db` (WAL) | | Mistral Vibe | — | `~/.vibe/logs/session/session_*/messages.jsonl` + `meta.json` | +| Muse | — | `~/Library/Caches/ConversationCache/hatch-*.json` | | AntiGravity | ✅ `AntigravityLiveAdapter` | `~/.gemini/antigravity{-cli,}/conversations/*.db`; state from the SQL columns plus a shallow `step_payload` decode; liveness via `presence/.lock` | | Cursor | ✅ `CursorLiveAdapter` | `~/.cursor/chats/**/store.db` + `~/.cursor/projects//agent-transcripts`; liveness via `cursor-agent-worker-*.pid` and the store's WAL | | Grok Bot | ✅ `GrokBotLiveAdapter` | `~/Library/Application Support/Grok Bot/sand-client-persistence/.blob`; the roster slice supplies the name and the needs-you flag; liveness via the `Grok Bot` process and `~/.grokbot/local-exec-supervisor.json` | diff --git a/implementations/rust/crates/agent-session-core/Cargo.toml b/implementations/rust/crates/agent-session-core/Cargo.toml index 7275cf2..f42ccbf 100644 --- a/implementations/rust/crates/agent-session-core/Cargo.toml +++ b/implementations/rust/crates/agent-session-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-session-core" -version = "0.10.0" +version = "0.11.0" edition = "2021" license = "AGPL-3.0-only" description = "Cross-platform Rust implementation of agent-session-kit's session reading: index queries, lightweight discovery, transcripts, and resume commands." diff --git a/implementations/rust/crates/agent-session-core/README.md b/implementations/rust/crates/agent-session-core/README.md index ea6e043..48868c3 100644 --- a/implementations/rust/crates/agent-session-core/README.md +++ b/implementations/rust/crates/agent-session-core/README.md @@ -52,8 +52,8 @@ let recent = reader.list(&SessionListFilter { limit: 20, ..Default::default() }) | Contract | Value | | --- | --- | | Index schema version | `PRAGMA user_version = 5` (any other value ⇒ refuse) | -| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse`, `devin`, `mistralVibe` | -| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot`, `Muse Code`, `Devin`, `Mistral Vibe` | +| Provider raw values | `claude`, `claudeCowork`, `codex`, `grok`, `cursor`, `gemini`, `antigravity`, `grokBot`, `muse`, `devin`, `mistralVibe`, `museAgent` | +| Harness display names | `Codex`, `ChatGPT Work`, `Claude Code`, `Claude Cowork`, `Gemini CLI`, `AntiGravity`, `Grok Build`, `Cursor`, `Grok Bot`, `Muse Code`, `Devin`, `Mistral Vibe`, `Muse` | | Index timestamps | Unix epoch seconds (INTEGER) | | FTS query | one double-quoted phrase, `""` escaping; needles < 3 chars use escaped `LIKE` | -| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse/mistralVibe ids; `[A-Za-z0-9._-]` for grok/gemini/devin; max 200 chars | +| Resume validation | uuid charset for claude/codex/cursor/antigravity/grokBot/muse/mistralVibe ids; `[A-Za-z0-9._-]` for grok/gemini/devin/museAgent; max 200 chars | diff --git a/implementations/rust/crates/agent-session-core/src/index.rs b/implementations/rust/crates/agent-session-core/src/index.rs index 1f39b27..cbec852 100644 --- a/implementations/rust/crates/agent-session-core/src/index.rs +++ b/implementations/rust/crates/agent-session-core/src/index.rs @@ -761,6 +761,7 @@ impl SessionIndexReader { "Muse Code" => "museCode", "Devin" => "devin", "Mistral Vibe" => "mistralVibe", + "Muse" => "museAgent", raw => raw, } } @@ -777,7 +778,8 @@ impl SessionIndexReader { WHEN 'grokBot' THEN 'grokBot' \ WHEN 'muse' THEN 'museCode' \ WHEN 'devin' THEN 'devin' \ - WHEN 'mistralVibe' THEN 'mistralVibe' END)" + WHEN 'mistralVibe' THEN 'mistralVibe' \ + WHEN 'museAgent' THEN 'museAgent' END)" } fn dedup_nonempty_strings( @@ -1049,6 +1051,43 @@ mod tests { } } + #[test] + fn muse_agent_rows_stay_apart_from_muse_code() { + let dir = tempfile::tempdir().unwrap(); + let path = fixture_index(dir.path()); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \ + VALUES(6, 'museAgent', 'hatch-main', NULL, 'muse app row', 1700006000, 1700006000, \ + '/Users/example/Library/Caches/ConversationCache/hatch-main.json'); \ + INSERT INTO sessions(id, provider, session_id, harness, title, created_at, last_active_at, source_path) \ + VALUES(7, 'muse', '01a0ac0d-5355-7c41-bc77-3b55f0e77ea1', 'museCode', 'muse cli row', \ + 1700007000, 1700007000, \ + '/Users/example/.local/share/muse/sessions/2026/01/01/01a0ac0d-5355-7c41-bc77-3b55f0e77ea1/session.jsonl');", + ) + .unwrap(); + drop(conn); + + let reader = SessionIndexReader::open(&path).unwrap(); + for (harness, provider, title) in [ + ("Muse", SessionProvider::MuseAgent, "muse app row"), + ("museAgent", SessionProvider::MuseAgent, "muse app row"), + ("Muse Code", SessionProvider::Muse, "muse cli row"), + ("museCode", SessionProvider::Muse, "muse cli row"), + ] { + let rows = reader + .list(&SessionListFilter { + harnesses: Some(vec![harness.to_string()]), + limit: 10, + ..Default::default() + }) + .unwrap(); + assert_eq!(rows.len(), 1, "{harness}"); + assert_eq!(rows[0].provider, provider); + assert_eq!(rows[0].title.as_deref(), Some(title)); + } + } + #[test] fn null_harness_uses_provider_default_before_filtering_and_offset() { let dir = tempfile::tempdir().unwrap(); diff --git a/implementations/rust/crates/agent-session-core/src/provider.rs b/implementations/rust/crates/agent-session-core/src/provider.rs index 5728d3b..123e56d 100644 --- a/implementations/rust/crates/agent-session-core/src/provider.rs +++ b/implementations/rust/crates/agent-session-core/src/provider.rs @@ -27,10 +27,12 @@ pub enum SessionProvider { Devin, #[serde(rename = "mistralVibe")] MistralVibe, + #[serde(rename = "museAgent")] + MuseAgent, } impl SessionProvider { - pub const ALL: [SessionProvider; 11] = [ + pub const ALL: [SessionProvider; 12] = [ SessionProvider::Claude, SessionProvider::ClaudeCowork, SessionProvider::Codex, @@ -42,6 +44,7 @@ impl SessionProvider { SessionProvider::Muse, SessionProvider::Devin, SessionProvider::MistralVibe, + SessionProvider::MuseAgent, ]; /// Storage raw value — identical to Swift's `rawValue`. @@ -58,6 +61,7 @@ impl SessionProvider { SessionProvider::Muse => "muse", SessionProvider::Devin => "devin", SessionProvider::MistralVibe => "mistralVibe", + SessionProvider::MuseAgent => "museAgent", } } @@ -82,6 +86,7 @@ impl SessionProvider { SessionProvider::Muse => "Muse Code", SessionProvider::Devin => "Devin", SessionProvider::MistralVibe => "Mistral Vibe", + SessionProvider::MuseAgent => "Muse", } } @@ -122,6 +127,22 @@ mod tests { SessionProvider::MistralVibe.default_harness(), "Mistral Vibe" ); + assert_eq!(SessionProvider::MuseAgent.raw_value(), "museAgent"); + assert_eq!(SessionProvider::MuseAgent.default_harness(), "Muse"); + assert_eq!( + serde_json::from_str::("\"museAgent\"").unwrap(), + SessionProvider::MuseAgent + ); + // Muse (the desktop agent app) and Muse Code (the `muse` CLI) are + // different stores and must never collapse onto one key. + assert_ne!( + SessionProvider::Muse.raw_value(), + SessionProvider::MuseAgent.raw_value() + ); + assert_ne!( + SessionProvider::Muse.default_harness(), + SessionProvider::MuseAgent.default_harness() + ); } #[test] @@ -134,6 +155,7 @@ mod tests { SessionProvider::Muse, SessionProvider::Devin, SessionProvider::MistralVibe, + SessionProvider::MuseAgent, ] { assert!(!provider.supports_deletion(), "{provider:?}"); } diff --git a/implementations/rust/crates/agent-session-core/src/resume.rs b/implementations/rust/crates/agent-session-core/src/resume.rs index 7015cd6..99b01f4 100644 --- a/implementations/rust/crates/agent-session-core/src/resume.rs +++ b/implementations/rust/crates/agent-session-core/src/resume.rs @@ -34,10 +34,14 @@ pub fn command( SessionProvider::Muse => Ok(format!("muse resume {id}")), SessionProvider::Devin => Ok(format!("devin --resume {id}")), SessionProvider::MistralVibe => Ok(format!("vibe --resume {id}")), - SessionProvider::ClaudeCowork | SessionProvider::Cursor | SessionProvider::GrokBot => { + SessionProvider::ClaudeCowork + | SessionProvider::Cursor + | SessionProvider::GrokBot + | SessionProvider::MuseAgent => { // Cowork runs inside Claude.app and Cursor's agents inside Cursor; // neither publishes a "reopen this conversation" command. Grok Bot - // has no CLI at all. + // has no CLI at all, and Muse's agent runs in a VM on Meta's + // servers. Err(SessionCoreError::ResumeUnavailable) } } @@ -85,8 +89,12 @@ fn is_valid(id: &str, provider: SessionProvider) -> bool { | SessionProvider::GrokBot | SessionProvider::Muse | SessionProvider::MistralVibe => id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'), - // Devin names a session with a word pair (`quiet-harbor`), not a UUID. - SessionProvider::Grok | SessionProvider::Gemini | SessionProvider::Devin => id + // Devin names a session with a word pair (`quiet-harbor`), not a UUID; + // Muse's is its cache file's stem (`hatch-main`). + SessionProvider::Grok + | SessionProvider::Gemini + | SessionProvider::Devin + | SessionProvider::MuseAgent => id .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')), } @@ -170,6 +178,14 @@ mod tests { command(SessionProvider::Devin, "quiet harbor; rm -rf /", None), Err(SessionCoreError::InvalidSessionId) )); + assert!(matches!( + command(SessionProvider::MuseAgent, "hatch-main", None), + Err(SessionCoreError::ResumeUnavailable) + )); + assert!(matches!( + command(SessionProvider::MuseAgent, "hatch main; rm -rf /", None), + Err(SessionCoreError::InvalidSessionId) + )); } #[test] diff --git a/implementations/swift/Sources/AgentSessionKit/AgentSessionKitInfo.swift b/implementations/swift/Sources/AgentSessionKit/AgentSessionKitInfo.swift index b48abc7..78b1797 100644 --- a/implementations/swift/Sources/AgentSessionKit/AgentSessionKitInfo.swift +++ b/implementations/swift/Sources/AgentSessionKit/AgentSessionKitInfo.swift @@ -19,7 +19,7 @@ public enum AgentSessionKitInfo { /// /// Semver, bare — no `v` prefix, matching the tags. Bumping it is part /// of the release commit; see `RELEASING.md`. - public static let version = "0.10.0" + public static let version = "0.11.0" /// Where the package lives. Public because a host that shows the /// version usually wants somewhere to send the reader. diff --git a/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift b/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift index 331a09e..85a9a67 100644 --- a/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift +++ b/implementations/swift/Sources/AgentSessionKit/Harness/Harness.swift @@ -19,6 +19,7 @@ import Foundation /// | Cursor | Anysphere | `~/.cursor/chats/**/store.db` | /// | Grok Bot | xAI | `~/Library/Application Support/Grok Bot/sand-client-persistence` — a cloud cache: conversations only, no model | /// | Muse Code | Meta AI | `~/.local/share/muse/sessions/YYYY/MM/DD//session.jsonl` | +/// | Muse | Meta AI | `~/Library/Caches/ConversationCache/hatch-*.json` — a cloud cache: conversations only, no model | /// | Devin | Cognition | `~/.local/share/devin/cli/sessions.db` — one database, every session a row | /// | Mistral Vibe | Mistral AI | `~/.vibe/logs/session/session_*/{meta.json,messages.jsonl}` | /// @@ -41,6 +42,10 @@ public enum HarnessCatalog { public static let grokBot = "Grok Bot" /// Meta's terminal coding agent, the `muse` CLI. public static let museCode = "Muse Code" + /// Meta's desktop agent app, `Muse.app` — muse.ai's Mac client, not the + /// `muse` CLI. Its agent runs in a VM on Meta's servers, so the local + /// store is a conversation cache and nothing a token counter could read. + public static let museAgent = "Muse" /// Cognition's local agent: the `devin` CLI and the Devin desktop app, /// whose "Devin Local" agent drives that CLI over ACP. Both write the one /// session database, so they are one harness. @@ -69,6 +74,7 @@ public enum Harness: String, CaseIterable, Codable, Sendable, Hashable { case cursor case grokBot case museCode + case museAgent case devin case mistralVibe @@ -84,6 +90,7 @@ public enum Harness: String, CaseIterable, Codable, Sendable, Hashable { case .cursor: HarnessCatalog.cursor case .grokBot: HarnessCatalog.grokBot case .museCode: HarnessCatalog.museCode + case .museAgent: HarnessCatalog.museAgent case .devin: HarnessCatalog.devin case .mistralVibe: HarnessCatalog.mistralVibe } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift new file mode 100644 index 0000000..9a36c02 --- /dev/null +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift @@ -0,0 +1,254 @@ +import Foundation + +/// Muse conversations: the local cache Meta's desktop agent app, `Muse.app` +/// (muse.ai's Mac client), keeps at `~/Library/Caches/ConversationCache/`. +/// +/// This is **not** Muse Code — that is Meta's `muse` terminal CLI, with its +/// own store and its own provider (`.muse`). The two share a company and a +/// word. +/// +/// The directory's name is generic and carries no bundle id because it is +/// shared: `Muse.app` and the consumer `Meta AI.app` are built from the same +/// code and both write here. Muse's conversations — the "Hatch" product line — +/// are the files named `hatch-.json` (today only `hatch-main.json`, +/// the main chat). The UUID-named files beside them are Meta AI's consumer +/// chats and are **not** Muse sessions; discovery never lists them. The +/// directory's dotfiles (`.cache_version`, `.user_id`) are the client's own +/// bookkeeping and are never opened. +/// +/// Each file is one JSON array, one element per message: `id`, `isUser`, +/// `content` (plain text / markdown), `timestamp` (seconds since the Apple +/// reference date, 2001-01-01 UTC — *not* the Unix epoch), `sortSeq`, +/// `isStreaming`, and `contentBlocks` (`markdown`, `thinkingStatus`, +/// `optionWidget`, `unsupported`), plus fields this adapter ignores +/// (`sources`, `mentions`, `activeToolCalls`, `hatchVMName`, +/// `rawUnifiedResponse`, …). +/// +/// **A cloud cache, so read-only and partial by nature.** The agent runs in a +/// VM on Meta's servers; this file is what the client happened to cache, which +/// means history may be incomplete and the running app rewrites it underneath +/// us. There is no model, no token count, no title, and no local project +/// directory to read. `deletionPlan` fails closed (AGENTS.md § 6) and there is +/// no resume command. +public struct MuseAgentSessionAdapter: SessionProviderAdapter { + public let provider: SessionProvider = .museAgent + + public init() {} + + /// Deliberately not `FileManager.cachesDirectory`: every discovery entry + /// point takes an explicit `homeDirectory` (AGENTS.md § 4), so tests point + /// it at a temp tree and a host passes `RealHomeDirectory.path`. + /// + /// Public so a host or a future live layer reads the same directory + /// without spelling it a second time. + public static let storeRelativePath = "Library/Caches/ConversationCache" + + /// Muse's files, and only Muse's, start with this. Meta AI's share the + /// directory under bare UUIDs. + public static let sessionFilePrefix = "hatch-" + public static let sessionFileExtension = "json" + + /// ``SessionSummary/providerVariant`` for every conversation in this + /// store. There is only the one kind. + public static let variant = "chat" + + /// Largest file we will read. Far above what the client writes (a few KB + /// to ~100 KB) and exists so a junk file in the directory costs nothing. + static let maxFileBytes: Int64 = 16 * 1024 * 1024 + + public func roots(homeDirectory: String) -> [URL] { + [URL(fileURLWithPath: homeDirectory).appendingPathComponent(Self.storeRelativePath)] + } + + /// The store is flat, so this lists one directory rather than walking a + /// tree: nothing below it is a Muse session. Symlinks and anything that is + /// not a regular file are skipped. + public func discoverSessionFiles(homeDirectory: String) -> [URL] { + roots(homeDirectory: homeDirectory).flatMap { root -> [URL] in + let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey] + let contents = (try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + )) ?? [] + return contents.filter { url in + guard Self.sessionID(at: url) != nil else { return false } + let values = try? url.resourceValues(forKeys: Set(keys)) + return values?.isSymbolicLink != true && values?.isRegularFile == true + } + .sorted { $0.path < $1.path } + } + } + + // MARK: - Session ids + + /// The session id a cache file stands for — its filename stem, e.g. + /// `hatch-main` — or `nil` when the file is not one of Muse's. + public static func sessionID(at url: URL) -> String? { + guard url.pathExtension == sessionFileExtension else { return nil } + let stem = url.deletingPathExtension().lastPathComponent + guard stem.hasPrefix(sessionFilePrefix), + stem.count > sessionFilePrefix.count, + isIdentifier(stem) + else { return nil } + return stem + } + + static func isIdentifier(_ value: String) -> Bool { + guard !value.isEmpty, value.count <= 200 else { return false } + return value.unicodeScalars.allSatisfy(identifierScalars.contains) + } + + private static let identifierScalars = CharacterSet(charactersIn: + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-" + ) + + // MARK: - Metadata + + public func extractMetadata(fileURL: URL) throws -> SessionSummary { + guard let sessionID = Self.sessionID(at: fileURL) else { + throw SessionParseError.invalidFormat("\(fileURL.lastPathComponent): not a Muse conversation cache") + } + let transcript = try Self.transcript(at: fileURL) + guard transcript.entryCount > 0 else { + throw SessionParseError.unreadable("\(fileURL.lastPathComponent): no messages") + } + + let firstPrompt = transcript.messages.first { $0.role == .user }?.text + let lastText = transcript.messages.last?.text + + return SessionSummary( + provider: .museAgent, + sessionID: sessionID, + providerVariant: Self.variant, + harness: .museAgent, + // Cloud-side inference: the cache never records which model + // answered, and guessing one would be wrong at pricing time too. + model: nil, + title: SessionParsing.display(firstPrompt, limit: SessionParsing.titleLimit), + summary: SessionParsing.display(lastText, limit: SessionParsing.summaryLimit), + // The agent's working directory is inside a remote VM. + projectDir: nil, + createdAt: transcript.firstStamp ?? SessionParsing.creationDate(fileURL), + lastActiveAt: transcript.lastStamp ?? SessionParsing.modificationDate(fileURL), + sourcePath: fileURL.path, + sizeBytes: SessionParsing.fileSize(fileURL), + messageCount: transcript.entryCount + ) + } + + // MARK: - Transcript + + public func parseTranscript(fileURL: URL, range: Range?) throws -> TranscriptDocument { + SessionTranscriptSlicing.document( + messages: try Self.transcript(at: fileURL).messages, + range: range + ) + } + + struct Transcript { + let messages: [SessionMessage] + /// Message entries — anything with an `isUser` flag — whether or not + /// they carried displayable text. This is what the list row counts, + /// so a widget-only reply still registers as a turn. + let entryCount: Int + let firstStamp: Date? + let lastStamp: Date? + } + + /// The file's messages, or a parse error: `unreadable` for a file that is + /// missing, oversized, or not JSON at all (the app may be mid-rewrite), + /// `invalidFormat` for JSON that is not this store's array. + static func transcript(at url: URL) throws -> Transcript { + guard SessionParsing.fileSize(url) <= maxFileBytes, + let data = try? Data(contentsOf: url), + let root = try? JSONSerialization.jsonObject(with: data) + else { throw SessionParseError.unreadable(url.lastPathComponent) } + guard let elements = root as? [Any] else { + throw SessionParseError.invalidFormat("\(url.lastPathComponent): not a message array") + } + return transcript(from: elements) + } + + static func transcript(from elements: [Any]) -> Transcript { + let entries = ordered(elements.compactMap { element -> [String: Any]? in + guard let entry = element as? [String: Any], isUser(entry) != nil else { return nil } + return entry + }) + + var messages: [SessionMessage] = [] + var first: Date? + var last: Date? + for entry in entries { + let stamp = date(entry["timestamp"]) + if let stamp { + first = min(first ?? stamp, stamp) + last = max(last ?? stamp, stamp) + } + // An empty text is a streaming placeholder or a widget-only + // reply; it counts as a turn but is not a bubble. + let body = text(of: entry) + guard !body.isEmpty else { continue } + let role: SessionRole = isUser(entry) == true ? .user : .assistant + messages.append(SessionMessage(seq: messages.count, role: role, text: body, timestamp: stamp)) + } + return Transcript(messages: messages, entryCount: entries.count, firstStamp: first, lastStamp: last) + } + + /// The entry's `isUser` flag, only when it is a real JSON boolean. + /// Foundation bridges `0` / `1` through `is Bool`, and a numeric flag is + /// not this store's shape — reading one would invent a role. + static func isUser(_ entry: [String: Any]) -> Bool? { + guard let number = entry["isUser"] as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { return nil } + return number.boolValue + } + + /// `sortSeq` order when every entry has one, file order otherwise. A + /// partial ordering key would interleave the unkeyed entries arbitrarily, + /// and the array is already the client's own idea of the conversation. + static func ordered(_ entries: [[String: Any]]) -> [[String: Any]] { + let keys = entries.map { SessionParsing.int($0["sortSeq"]) } + guard !keys.contains(where: { $0 == nil }) else { return entries } + return entries.indices + .sorted { (keys[$0]!, $0) < (keys[$1]!, $1) } + .map { entries[$0] } + } + + /// `content`, or — when that is empty — the text of the message's + /// markdown blocks. Thinking, widget, and unsupported blocks carry no + /// conversation text. + static func text(of entry: [String: Any]) -> String { + if let content = SessionParsing.string(entry["content"]) { return content } + let blocks = entry["contentBlocks"] as? [Any] ?? [] + let parts = blocks.compactMap { block -> String? in + guard let markdown = (block as? [String: Any])?["markdown"] as? [String: Any] else { return nil } + return SessionParsing.string(markdown["text"]) + } + return parts.joined(separator: "\n\n") + } + + /// The client stamps messages in seconds since the Apple reference date + /// (2001-01-01 UTC), which is `Date`'s own reference. Reading one as Unix + /// seconds would land every conversation in 1995. + static func date(_ value: Any?) -> Date? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + let seconds = number.doubleValue + guard seconds.isFinite, seconds > 0, seconds < maxReferenceSeconds else { return nil } + return Date(timeIntervalSinceReferenceDate: seconds) + } + + /// Past this a value is not reference-date seconds (it would be well into + /// the 23rd century) — most likely milliseconds — and is ignored rather + /// than turned into a wrong date. + static let maxReferenceSeconds: Double = 10_000_000_000 + + // MARK: - Deletion + + public func deletionPlan(for summary: SessionSummary, homeDirectory: String) throws -> SessionDeletionPlan { + throw SessionDeleteError.providerIsReadOnly(.museAgent) + } +} diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift index 7ab2a84..cf15798 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionModels.swift @@ -8,8 +8,8 @@ import Foundation /// ChatGPT Work depending on its `originator`), which is why every summary /// also carries a `harness`. /// -/// Seven providers are listed and readable but never deletable, because -/// another running app — or, for Grok Bot, a server — owns the store; see +/// Eight providers are listed and readable but never deletable, because +/// another running app — or, for Grok Bot and Muse, a server — owns the store; see /// `supportsDeletion`. public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case claude @@ -23,6 +23,7 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case muse case devin case mistralVibe + case museAgent public var displayName: String { switch self { @@ -37,6 +38,7 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case .muse: return HarnessCatalog.museCode case .devin: return HarnessCatalog.devin case .mistralVibe: return HarnessCatalog.mistralVibe + case .museAgent: return HarnessCatalog.museAgent } } @@ -56,6 +58,7 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { case .muse: return .museCode case .devin: return .devin case .mistralVibe: return .mistralVibe + case .museAgent: return .museAgent } } @@ -69,15 +72,18 @@ public enum SessionProvider: String, Codable, Sendable, CaseIterable, Hashable { /// Muse Code's session directories, which the CLI's own SQLite index /// references and a running `muse` holds locks and sockets inside, /// Devin's sessions, which are rows in one database the CLI holds open, - /// and Mistral Vibe's session directories, which the CLI indexes, leases, - /// and nests sub-agent sessions inside. + /// Mistral Vibe's session directories, which the CLI indexes, leases, + /// and nests sub-agent sessions inside, and Muse's conversation cache, + /// which the running app rewrites and whose conversations live on Meta's + /// servers. /// The adapters fail closed with /// `SessionDeleteError.providerIsReadOnly`; this is the same fact /// stated where a UI can ask it before offering the action. public var supportsDeletion: Bool { switch self { case .claude, .codex, .grok, .gemini: return true - case .claudeCowork, .cursor, .antigravity, .grokBot, .muse, .devin, .mistralVibe: return false + case .claudeCowork, .cursor, .antigravity, .grokBot, .muse, .devin, .mistralVibe, .museAgent: + return false } } } @@ -330,6 +336,8 @@ public enum SessionDeleteError: Error, Hashable, Codable, Sendable { return "Devin keeps every session in one live database; remove them with devin rm." case .mistralVibe: return "Mistral Vibe indexes and leases its own sessions; manage them from the vibe CLI." + case .museAgent: + return "Muse sessions are the app's own cloud cache; manage them in Muse." case .claude, .codex, .grok, .gemini: return "Deleting sessions is not supported for this provider." } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift index 6e6ee3b..ea1a944 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionProviderAdapter.swift @@ -172,9 +172,9 @@ public struct SessionProviderRegistry: Sendable { /// The adapters shipped today, one per `SessionProvider`. /// - /// AntiGravity, Claude Cowork, Cursor, Grok Bot, Muse Code, Devin, and - /// Mistral Vibe list and read like the rest but refuse to plan a delete, - /// because another running app owns those stores — see + /// AntiGravity, Claude Cowork, Cursor, Grok Bot, Muse Code, Devin, + /// Mistral Vibe, and Muse list and read like the rest but refuse to plan a + /// delete, because another running app owns those stores — see /// `SessionProvider.supportsDeletion`. public static func standard(homeDirectory: String = RealHomeDirectory.path) -> SessionProviderRegistry { SessionProviderRegistry(adapters: [ @@ -188,7 +188,8 @@ public struct SessionProviderRegistry: Sendable { GrokBotSessionAdapter(), MuseSessionAdapter(), DevinSessionAdapter(), - MistralVibeSessionAdapter() + MistralVibeSessionAdapter(), + MuseAgentSessionAdapter() ]) } diff --git a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift index bf62d56..b13b471 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/SessionResumeCommandBuilder.swift @@ -53,12 +53,13 @@ public enum SessionResumeCommandBuilder { return "devin --resume \(id)" case .mistralVibe: return "vibe --resume \(id)" - case .claudeCowork, .cursor, .grokBot: + case .claudeCowork, .cursor, .grokBot, .museAgent: // Cowork runs inside Claude.app and Cursor's agents inside // Cursor; neither publishes a "reopen this conversation" // command, and inventing one would hand the user a line that // silently starts a *new* session. Grok Bot goes further: the - // conversation runs on xAI's servers and there is no CLI at all. + // conversation runs on xAI's servers and there is no CLI at all, + // and Muse's agent runs in a VM on Meta's servers. throw SessionResumeError.resumeUnavailable } } @@ -87,8 +88,9 @@ public enum SessionResumeCommandBuilder { switch provider { case .claude, .claudeCowork, .codex, .cursor, .antigravity, .grokBot, .muse, .mistralVibe: return id.unicodeScalars.allSatisfy { uuidCharacters.contains($0) } - case .grok, .gemini, .devin: - // Devin names a session with a word pair (`quiet-harbor`), not a UUID. + case .grok, .gemini, .devin, .museAgent: + // Devin names a session with a word pair (`quiet-harbor`), not a UUID; + // Muse's is its cache file's stem (`hatch-main`). return id.unicodeScalars.allSatisfy { looseCharacters.contains($0) } } } diff --git a/implementations/swift/Sources/AgentSessionLive/Events/SessionResume.swift b/implementations/swift/Sources/AgentSessionLive/Events/SessionResume.swift index 816b005..1ba3355 100644 --- a/implementations/swift/Sources/AgentSessionLive/Events/SessionResume.swift +++ b/implementations/swift/Sources/AgentSessionLive/Events/SessionResume.swift @@ -86,7 +86,7 @@ public enum SessionResume: Sendable { /// a given session: only its CLI surface takes a conversation id. public static func isResumable(_ harness: Harness) -> Bool { switch harness { - case .claudeCowork, .cursor, .grokBot: false + case .claudeCowork, .cursor, .grokBot, .museAgent: false case .codex, .chatgptWork, .claudeCode, .geminiCLI, .antigravity, .grokBuild, .museCode, .devin, .mistralVibe: true } @@ -126,6 +126,8 @@ public enum SessionResume: Sendable { return "Cursor's agents live inside Cursor and have no command-line resume." case .grokBot: return "Grok Bot conversations run on xAI's servers; there is nothing local to resume." + case .museAgent: + return "Muse conversations run on Meta's servers; there is nothing local to resume." case .antigravity: return "Only AntiGravity's CLI sessions take a conversation id; an IDE session has none." case .codex, .chatgptWork, .claudeCode, .geminiCLI, .grokBuild, .museCode, .devin, .mistralVibe: @@ -153,6 +155,7 @@ public extension Harness { case .cursor: .cursor case .grokBot: .grokBot case .museCode: .muse + case .museAgent: .museAgent case .devin: .devin case .mistralVibe: .mistralVibe } diff --git a/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift b/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift index 2b49eaf..dba2699 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/HarnessNamingTests.swift @@ -18,6 +18,7 @@ final class HarnessNamingTests: XCTestCase { "Cursor", "Grok Bot", "Muse Code", + "Muse", "Devin", "Mistral Vibe" ] @@ -41,7 +42,7 @@ final class HarnessNamingTests: XCTestCase { [ "codex", "chatgptWork", "claudeCode", "claudeCowork", "geminiCLI", "antigravity", "grokBuild", "cursor", "grokBot", "museCode", - "devin", "mistralVibe" + "museAgent", "devin", "mistralVibe" ] ) } @@ -52,7 +53,7 @@ final class HarnessNamingTests: XCTestCase { XCTAssertEqual( SessionProvider.allCases.map(\.defaultHarness), [.claudeCode, .claudeCowork, .codex, .grokBuild, .cursor, .geminiCLI, - .antigravity, .grokBot, .museCode, .devin, .mistralVibe] + .antigravity, .grokBot, .museCode, .devin, .mistralVibe, .museAgent] ) XCTAssertEqual( Set(Harness.allCases).subtracting(SessionProvider.allCases.map(\.defaultHarness)), @@ -73,12 +74,26 @@ final class HarnessNamingTests: XCTestCase { XCTAssertEqual(Harness.devin.displayName, "Devin") XCTAssertEqual(SessionProvider.mistralVibe.displayName, HarnessCatalog.mistralVibe) XCTAssertEqual(Harness.mistralVibe.displayName, "Mistral Vibe") + XCTAssertEqual(SessionProvider.museAgent.displayName, HarnessCatalog.museAgent) + XCTAssertEqual(Harness.museAgent.displayName, "Muse") } /// The provider raw values are storage keys too. func testNewProviderRawValuesAreStable() { XCTAssertEqual(SessionProvider.devin.rawValue, "devin") XCTAssertEqual(SessionProvider.mistralVibe.rawValue, "mistralVibe") + XCTAssertEqual(SessionProvider.museAgent.rawValue, "museAgent") + XCTAssertEqual(Harness.museAgent.rawValue, "museAgent") + } + + /// Muse (the desktop agent app) and Muse Code (the `muse` CLI) share a + /// company and a word, and nothing on disk. Collapsing them would put a + /// CLI's name on a cloud conversation. + func testMuseCodeAndMuseAreDistinctHarnesses() { + XCTAssertNotEqual(Harness.museCode, Harness.museAgent) + XCTAssertNotEqual(SessionProvider.muse, SessionProvider.museAgent) + XCTAssertEqual(SessionProvider.muse.defaultHarness, .museCode) + XCTAssertEqual(SessionProvider.museAgent.defaultHarness, .museAgent) } /// Grok Build and Grok Bot share a company and nothing else: one is a diff --git a/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift new file mode 100644 index 0000000..664bbe1 --- /dev/null +++ b/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift @@ -0,0 +1,328 @@ +import XCTest +@testable import AgentSessionKit + +final class MuseAgentSessionAdapterTests: XCTestCase { + private var home: URL! + private let adapter = MuseAgentSessionAdapter() + + /// 2026-01-01T00:00:00Z as seconds since the Apple reference date — the + /// clock the client writes. As Unix seconds it would be 1995. + private let referenceStart: Double = 788_918_400 + private let unixStart: Double = 1_767_225_600 + + override func setUpWithError() throws { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("ASKMuseAgentAdapterTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: storeDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: home) + } + + private var storeDirectory: URL { + home.appendingPathComponent(MuseAgentSessionAdapter.storeRelativePath, isDirectory: true) + } + + // MARK: - Fixtures + + @discardableResult + private func write(_ name: String, _ text: String) throws -> URL { + let url = storeDirectory.appendingPathComponent(name) + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } + + @discardableResult + private func write(_ name: String, messages: [[String: Any]]) throws -> URL { + let data = try JSONSerialization.data(withJSONObject: messages) + let url = storeDirectory.appendingPathComponent(name) + try data.write(to: url) + return url + } + + private func message( + _ id: String, + user: Bool, + content: String, + at offset: Double, + seq: Int?, + streaming: Bool = false, + blocks: [[String: Any]]? = nil + ) -> [String: Any] { + var out: [String: Any] = [ + "id": id, + "isUser": user, + "content": content, + "timestamp": referenceStart + offset, + "isStreaming": streaming, + "contentBlocks": blocks ?? (content.isEmpty ? [] : [["markdown": ["text": content, "inlineEntities": []]]]), + "sources": [], + "mentions": [], + "inlineEntities": [], + "activeToolCalls": [], + "hatchVMName": "vm-synthetic", + "rawUnifiedResponse": "{\"__typename\":\"Synthetic\"}" + ] + if let seq { out["sortSeq"] = seq } + return out + } + + /// The main chat, written out of order on purpose: `sortSeq` is the order. + @discardableResult + private func writeMainChat() throws -> URL { + try write("hatch-main.json", messages: [ + message("m2", user: false, content: "", at: 60, seq: 2, blocks: [ + ["thinkingStatus": ["text": "Thinking"]], + ["markdown": ["text": "Three steps.", "inlineEntities": []]], + ["optionWidget": ["options": ["a", "b"]]], + ["markdown": ["text": "First, read the notes.", "inlineEntities": []]] + ]), + message("m1", user: true, content: "Plan the week for the garden club.", at: 0, seq: 1), + message("m3", user: true, content: "Pick option a.", at: 120, seq: 3), + message("m4", user: false, content: "Done: option a is booked.", at: 180, seq: 4), + // A reply still streaming, with nothing in it yet. + message("m5", user: false, content: "", at: 240, seq: 5, streaming: true) + ]) + } + + // MARK: - Discovery + + func testRootIsExactlyTheConversationCache() { + XCTAssertEqual( + adapter.roots(homeDirectory: "/Users/example").map(\.path), + ["/Users/example/Library/Caches/ConversationCache"] + ) + } + + /// Meta AI.app writes UUID-named chats into the same directory; only + /// `hatch-*.json` is Muse's. + func testDiscoveryListsOnlyHatchFiles() throws { + try writeMainChat() + try write("hatch-side_2.json", "[]") + try write("0ca942de-1111-2222-3333-444444444444.json", "[]") + try write(".cache_version", "12") + try write(".user_id", "synthetic") + try write("hatch-.json", "[]") + try write("hatch-bad name.json", "[]") + try write("hatch-main.txt", "[]") + try write("main.json", "[]") + let nested = storeDirectory.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try "[]".write(to: nested.appendingPathComponent("hatch-deep.json"), atomically: true, encoding: .utf8) + let outside = home.appendingPathComponent("outside.json") + try "[]".write(to: outside, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: storeDirectory.appendingPathComponent("hatch-link.json"), withDestinationURL: outside + ) + + let found = adapter.discoverSessionFiles(homeDirectory: home.path).map(\.lastPathComponent) + XCTAssertEqual(found, ["hatch-main.json", "hatch-side_2.json"]) + } + + func testMissingStoreDiscoversNothing() throws { + try FileManager.default.removeItem(at: storeDirectory) + XCTAssertEqual(adapter.discoverSessionFiles(homeDirectory: home.path), []) + } + + func testSessionIDIsTheFileStem() { + let dir = URL(fileURLWithPath: "/Users/example/Library/Caches/ConversationCache") + XCTAssertEqual(MuseAgentSessionAdapter.sessionID(at: dir.appendingPathComponent("hatch-main.json")), "hatch-main") + XCTAssertNil(MuseAgentSessionAdapter.sessionID(at: dir.appendingPathComponent("0ca942de-1111-2222-3333-444444444444.json"))) + XCTAssertNil(MuseAgentSessionAdapter.sessionID(at: dir.appendingPathComponent("hatch-a.b.json"))) + XCTAssertNil(MuseAgentSessionAdapter.sessionID(at: dir.appendingPathComponent("hatch-$(x).json"))) + } + + // MARK: - Metadata + + func testMetadata() throws { + let url = try writeMainChat() + let summary = try adapter.extractMetadata(fileURL: url) + + XCTAssertEqual(summary.provider, .museAgent) + XCTAssertEqual(summary.harness, .museAgent) + XCTAssertEqual(summary.effectiveHarness.displayName, "Muse") + XCTAssertEqual(summary.sessionID, "hatch-main") + XCTAssertEqual(summary.providerVariant, MuseAgentSessionAdapter.variant) + XCTAssertNil(summary.model) + XCTAssertNil(summary.projectDir) + XCTAssertEqual(summary.title, "Plan the week for the garden club.") + XCTAssertEqual(summary.summary, "Done: option a is booked.") + // Every entry counts as a turn, including the empty streaming one. + XCTAssertEqual(summary.messageCount, 5) + XCTAssertEqual(summary.createdAt, Date(timeIntervalSince1970: unixStart)) + XCTAssertEqual(summary.lastActiveAt, Date(timeIntervalSince1970: unixStart + 240)) + XCTAssertEqual(summary.sourcePath, url.path) + XCTAssertGreaterThan(summary.sizeBytes, 0) + } + + func testLongFirstPromptIsTruncatedToOneLine() throws { + let long = "Line one\nline two " + String(repeating: "x", count: 200) + let url = try write("hatch-main.json", messages: [ + message("m1", user: true, content: long, at: 0, seq: 1) + ]) + let title = try XCTUnwrap(adapter.extractMetadata(fileURL: url).title) + XCTAssertFalse(title.contains("\n")) + XCTAssertTrue(title.hasPrefix("Line one line two xxx")) + XCTAssertTrue(title.hasSuffix("…")) + XCTAssertLessThanOrEqual(title.count, SessionParsing.titleLimit + 1) + } + + // MARK: - Transcript + + func testTranscriptFollowsSortSeqAndReadsMarkdownBlocks() throws { + let url = try writeMainChat() + let document = try adapter.parseTranscript(fileURL: url, range: nil) + + XCTAssertEqual(document.messages.map(\.role), [.user, .assistant, .user, .assistant]) + XCTAssertEqual(document.messages.map(\.text), [ + "Plan the week for the garden club.", + "Three steps.\n\nFirst, read the notes.", + "Pick option a.", + "Done: option a is booked." + ]) + XCTAssertEqual(document.messages.map(\.seq), [0, 1, 2, 3]) + XCTAssertEqual(document.messages[1].timestamp, Date(timeIntervalSince1970: unixStart + 60)) + XCTAssertFalse(document.truncated) + } + + func testTranscriptRange() throws { + let url = try writeMainChat() + let document = try adapter.parseTranscript(fileURL: url, range: 1..<3) + XCTAssertEqual(document.messages.map(\.text), ["Three steps.\n\nFirst, read the notes.", "Pick option a."]) + XCTAssertEqual(document.totalMessageCount, 4) + XCTAssertTrue(document.truncated) + } + + /// One entry without `sortSeq` means there is no total order to trust; + /// the file's own order stands. + func testMissingSortSeqKeepsFileOrder() throws { + let url = try write("hatch-main.json", messages: [ + message("b", user: false, content: "second", at: 60, seq: 9), + message("a", user: true, content: "first", at: 0, seq: nil) + ]) + let document = try adapter.parseTranscript(fileURL: url, range: nil) + XCTAssertEqual(document.messages.map(\.text), ["second", "first"]) + } + + func testContentWinsOverBlocks() throws { + let url = try write("hatch-main.json", messages: [ + message("a", user: false, content: "plain", at: 0, seq: 1, blocks: [ + ["markdown": ["text": "rich", "inlineEntities": []]] + ]) + ]) + XCTAssertEqual(try adapter.parseTranscript(fileURL: url, range: nil).messages.map(\.text), ["plain"]) + } + + // MARK: - Apple reference-date timestamps + + func testTimestampsAreAppleReferenceSeconds() { + XCTAssertEqual( + MuseAgentSessionAdapter.date(NSNumber(value: referenceStart)), + Date(timeIntervalSince1970: unixStart) + ) + XCTAssertEqual( + MuseAgentSessionAdapter.date(NSNumber(value: 811_480_307.25)), + Date(timeIntervalSince1970: 811_480_307.25 + 978_307_200) + ) + XCTAssertNil(MuseAgentSessionAdapter.date(true)) + XCTAssertNil(MuseAgentSessionAdapter.date(NSNumber(value: 0))) + XCTAssertNil(MuseAgentSessionAdapter.date(NSNumber(value: -5))) + XCTAssertNil(MuseAgentSessionAdapter.date(NSNumber(value: referenceStart * 1000))) + XCTAssertNil(MuseAgentSessionAdapter.date("788918400")) + XCTAssertNil(MuseAgentSessionAdapter.date(nil)) + } + + // MARK: - Deletion + + func testDeletionFailsClosed() throws { + let summary = try adapter.extractMetadata(fileURL: writeMainChat()) + XCTAssertFalse(SessionProvider.museAgent.supportsDeletion) + XCTAssertThrowsError(try adapter.deletionPlan(for: summary, homeDirectory: home.path)) { error in + XCTAssertEqual(error as? SessionDeleteError, .providerIsReadOnly(.museAgent)) + } + let outcomes = SessionDeleter().delete( + [summary], registry: .standard(homeDirectory: home.path) + ) + XCTAssertFalse(outcomes[0].success) + XCTAssertEqual(outcomes[0].failureReason, .providerIsReadOnly(.museAgent)) + XCTAssertTrue(FileManager.default.fileExists(atPath: summary.sourcePath)) + } + + // MARK: - Malformed input + + func testNumericIsUserIsNotAMessage() throws { + // `0` / `1` bridge through `is Bool`; a numeric flag is not this + // store's shape and must not be given an invented role. + let url = try write("hatch-main.json", """ + [{"id":"a","isUser":1,"content":"numeric user","timestamp":\(referenceStart)}, + {"id":"b","isUser":0,"content":"numeric reply","timestamp":\(referenceStart + 1)}, + {"id":"c","isUser":true,"content":"real prompt","timestamp":\(referenceStart + 2)}] + """) + let summary = try adapter.extractMetadata(fileURL: url) + XCTAssertEqual(summary.messageCount, 1) + XCTAssertEqual(summary.title, "real prompt") + let document = try adapter.parseTranscript(fileURL: url, range: nil) + XCTAssertEqual(document.messages.map(\.text), ["real prompt"]) + XCTAssertEqual(document.messages.map(\.role), [.user]) + } + + func testMalformedFilesFailWithTheTwoParseErrors() throws { + let truncated = try write("hatch-main.json", "[{\"id\":\"a\",\"isUser\":tr") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: truncated)) { error in + guard case .unreadable = error as? SessionParseError else { return XCTFail("\(error)") } + } + XCTAssertThrowsError(try adapter.parseTranscript(fileURL: truncated, range: nil)) + + let object = try write("hatch-object.json", "{\"messages\":[]}") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: object)) { error in + guard case .invalidFormat = error as? SessionParseError else { return XCTFail("\(error)") } + } + + let empty = try write("hatch-empty.json", "[]") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: empty)) { error in + guard case .unreadable = error as? SessionParseError else { return XCTFail("\(error)") } + } + XCTAssertEqual(try adapter.parseTranscript(fileURL: empty, range: nil).messages, []) + + let foreign = try write("0ca942de-1111-2222-3333-444444444444.json", "[]") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: foreign)) { error in + guard case .invalidFormat = error as? SessionParseError else { return XCTFail("\(error)") } + } + + let missing = storeDirectory.appendingPathComponent("hatch-gone.json") + XCTAssertThrowsError(try adapter.extractMetadata(fileURL: missing)) + } + + /// Wrong types in every field: skipped, never a crash, and the one good + /// entry still reads. + func testJunkEntriesAreSkipped() throws { + let url = try write("hatch-main.json", """ + [ + 1, "text", null, [], + {"id":"x","content":"no isUser flag","timestamp":788918400}, + {"id":"y","isUser":"yes","content":"isUser is a string"}, + {"id":"z","isUser":false,"content":42,"contentBlocks":"nope","timestamp":"soon","sortSeq":"one"}, + {"id":"w","isUser":false,"content":"","contentBlocks":[1,{"markdown":"flat"},{"markdown":{"text":7}},{"unsupported":{}}]}, + {"id":"ok","isUser":true,"content":" the one good line ","timestamp":788918460,"sortSeq":2} + ] + """) + let summary = try adapter.extractMetadata(fileURL: url) + XCTAssertEqual(summary.messageCount, 3) + XCTAssertEqual(summary.title, "the one good line") + XCTAssertEqual(summary.createdAt, Date(timeIntervalSince1970: unixStart + 60)) + XCTAssertEqual(summary.lastActiveAt, Date(timeIntervalSince1970: unixStart + 60)) + + let document = try adapter.parseTranscript(fileURL: url, range: nil) + XCTAssertEqual(document.messages.map(\.text), ["the one good line"]) + XCTAssertEqual(document.messages.map(\.role), [.user]) + } + + // MARK: - Registry + + func testRegisteredInTheStandardRegistry() { + let registry = SessionProviderRegistry.standard(homeDirectory: "/Users/example") + XCTAssertTrue(registry.adapter(for: .museAgent) is MuseAgentSessionAdapter) + XCTAssertTrue(registry.adapter(for: .muse) is MuseSessionAdapter) + } +} diff --git a/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift index 1819bd3..5c629b1 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/SessionDeleterTests.swift @@ -178,7 +178,8 @@ final class SessionDeleterTests: XCTestCase { .grokBot: "Library/Application Support/Grok Bot/sand-client-persistence/\(sessionID).blob", .muse: ".local/share/muse/sessions/2026/01/01/\(sessionID)/session.jsonl", .devin: ".local/share/devin/cli/sessions.db/\(sessionID)", - .mistralVibe: ".vibe/logs/session/session_20260101_000000_\(sessionID.prefix(8))/messages.jsonl" + .mistralVibe: ".vibe/logs/session/session_20260101_000000_\(sessionID.prefix(8))/messages.jsonl", + .museAgent: "Library/Caches/ConversationCache/hatch-main.json" ] for (provider, path) in paths { XCTAssertFalse(provider.supportsDeletion, "\(provider) must not be deletable") @@ -202,7 +203,7 @@ final class SessionDeleterTests: XCTestCase { } XCTAssertEqual( Set(SessionProvider.allCases.filter { !$0.supportsDeletion }), - [.antigravity, .cursor, .claudeCowork, .grokBot, .muse, .devin, .mistralVibe] + [.antigravity, .cursor, .claudeCowork, .grokBot, .muse, .devin, .mistralVibe, .museAgent] ) } diff --git a/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift b/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift index 6dc3822..caa5726 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/SessionResumeCommandBuilderTests.swift @@ -68,6 +68,17 @@ final class SessionResumeCommandBuilderTests: XCTestCase { /// Every provider either builds a command or refuses for a stated /// reason — a new case must not fall through to a wrong CLI. + /// Muse's agent runs in a VM on Meta's servers; there is no command that + /// reopens one of its conversations, whatever the id looks like. + func testMuseAgentHasNoResume() { + assertThrows(.resumeUnavailable) { + try SessionResumeCommandBuilder.command(provider: .museAgent, sessionID: "hatch-main") + } + assertThrows(.invalidSessionID) { + try SessionResumeCommandBuilder.command(provider: .museAgent, sessionID: "hatch main; rm -rf /") + } + } + func testEveryProviderIsAccountedFor() { let resumable: Set = [ .claude, .codex, .grok, .gemini, .antigravity, .muse, .devin, .mistralVibe diff --git a/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift b/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift index b075a6a..9e5ba37 100644 --- a/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift +++ b/implementations/swift/Tests/AgentSessionLiveTests/SessionResumeTests.swift @@ -56,8 +56,8 @@ struct SessionResumeTests { // MARK: - The harnesses without one - @Test("Claude Cowork, Cursor, and Grok Bot cannot be resumed", arguments: [ - Harness.claudeCowork, .cursor, .grokBot, + @Test("Claude Cowork, Cursor, Grok Bot, and Muse cannot be resumed", arguments: [ + Harness.claudeCowork, .cursor, .grokBot, .museAgent, ]) func harnessesWithoutAResume(_ harness: Harness) { let availability = SessionResume.availability(for: Self.identity(harness)) @@ -119,6 +119,7 @@ struct SessionResumeTests { #expect(Harness.museCode.sessionProvider == .muse) #expect(Harness.devin.sessionProvider == .devin) #expect(Harness.mistralVibe.sessionProvider == .mistralVibe) + #expect(Harness.museAgent.sessionProvider == .museAgent) } @Test("the mapping is the inverse of the provider's default harness")