From 1d25299f5c1ff5c0387965eb8f277bcd98f5291a Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:34:41 +0800 Subject: [PATCH 1/2] feat: list and read Muse app sessions Meta's desktop agent app, Muse.app (muse.ai's Mac client), caches each conversation at ~/Library/Caches/ConversationCache/ as one JSON array of messages. The directory is shared with the consumer Meta AI.app, which is built from the same code, so MuseAgentSessionAdapter lists only the hatch-.json files, flat and without following links, and never the UUID-named Meta AI chats beside them. The session id is the file stem. Messages are ordered by sortSeq when every entry has one, roles come from isUser, and text is content or, when that is empty, the markdown blocks. Timestamps are seconds since the Apple reference date and are converted as such. The title is the first prompt; model, token counts and project directory stay nil because the agent runs in a VM on Meta's servers. SessionProvider.museAgent / Harness.museAgent ("Muse") are read-only and have no resume command. Muse Code (the muse CLI) is unchanged. The Rust lane mirrors the provider, and its index reader filters on "Muse" and museAgent. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +- CHANGELOG.md | 22 ++ README.md | 18 +- .../rust/crates/agent-session-core/README.md | 6 +- .../crates/agent-session-core/src/index.rs | 41 ++- .../crates/agent-session-core/src/provider.rs | 24 +- .../crates/agent-session-core/src/resume.rs | 24 +- .../AgentSessionKit/Harness/Harness.swift | 7 + .../Sessions/MuseAgentSessionAdapter.swift | 244 ++++++++++++++ .../Sessions/SessionModels.swift | 18 +- .../Sessions/SessionProviderAdapter.swift | 9 +- .../SessionResumeCommandBuilder.swift | 10 +- .../Events/SessionResume.swift | 5 +- .../HarnessNamingTests.swift | 19 +- .../MuseAgentSessionAdapterTests.swift | 312 ++++++++++++++++++ .../SessionDeleterTests.swift | 5 +- .../SessionResumeCommandBuilderTests.swift | 11 + .../SessionResumeTests.swift | 5 +- 18 files changed, 754 insertions(+), 33 deletions(-) create mode 100644 implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift create mode 100644 implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift 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..5334aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### 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. 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/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/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/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..17a4f44 --- /dev/null +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift @@ -0,0 +1,244 @@ +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], entry["isUser"] is Bool 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 = (entry["isUser"] as? Bool) == 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) + } + + /// `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..b153d8c --- /dev/null +++ b/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift @@ -0,0 +1,312 @@ +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 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") From 406627b02a52f00ce242d20f81b66a7e572fed69 Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:21:31 +0800 Subject: [PATCH 2/2] fix: skip Muse entries whose isUser is not a JSON boolean; release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++++++++- Cargo.lock | 2 +- .../rust/crates/agent-session-core/Cargo.toml | 2 +- .../AgentSessionKit/AgentSessionKitInfo.swift | 2 +- .../Sessions/MuseAgentSessionAdapter.swift | 14 ++++++++++++-- .../MuseAgentSessionAdapterTests.swift | 16 ++++++++++++++++ 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5334aa3..9216e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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` @@ -20,7 +26,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. The title is the first prompt, + 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. 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/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/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/Sessions/MuseAgentSessionAdapter.swift b/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift index 17a4f44..9a36c02 100644 --- a/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift +++ b/implementations/swift/Sources/AgentSessionKit/Sessions/MuseAgentSessionAdapter.swift @@ -172,7 +172,7 @@ public struct MuseAgentSessionAdapter: SessionProviderAdapter { static func transcript(from elements: [Any]) -> Transcript { let entries = ordered(elements.compactMap { element -> [String: Any]? in - guard let entry = element as? [String: Any], entry["isUser"] is Bool else { return nil } + guard let entry = element as? [String: Any], isUser(entry) != nil else { return nil } return entry }) @@ -189,12 +189,22 @@ public struct MuseAgentSessionAdapter: SessionProviderAdapter { // reply; it counts as a turn but is not a bubble. let body = text(of: entry) guard !body.isEmpty else { continue } - let role: SessionRole = (entry["isUser"] as? Bool) == true ? .user : .assistant + 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. diff --git a/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift b/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift index b153d8c..664bbe1 100644 --- a/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift +++ b/implementations/swift/Tests/AgentSessionKitTests/MuseAgentSessionAdapterTests.swift @@ -251,6 +251,22 @@ final class MuseAgentSessionAdapterTests: XCTestCase { // 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