diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift index e508f7f..7c86372 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift @@ -15,7 +15,7 @@ import Observation @Observable @MainActor public final class IOSMemoryViewModel { - public enum Kind: Sendable, Equatable { + public enum Kind: Sendable, Equatable, CaseIterable { /// `~/.hermes/memories/MEMORY.md` — the agent's persistent /// memory. Visible (and editable) to the agent at every /// session start. @@ -23,12 +23,18 @@ public final class IOSMemoryViewModel { /// `~/.hermes/memories/USER.md` — user-profile notes the /// agent reads but (by default) does not write. case user + /// `~/.hermes/SOUL.md` — the agent's persona / character + /// (voice, tone, style). Lives in the Personalities feature + /// on macOS; on iOS we fold it into Memory so the whole + /// "edit the agent's prompt inputs" surface is in one place. + case soul /// Heading shown in the UI. public var displayName: String { switch self { case .memory: return "MEMORY.md" case .user: return "USER.md" + case .soul: return "SOUL.md" } } @@ -37,6 +43,7 @@ public final class IOSMemoryViewModel { switch self { case .memory: return "brain.head.profile" case .user: return "person.crop.square" + case .soul: return "sparkles" } } @@ -47,16 +54,19 @@ public final class IOSMemoryViewModel { return "Agent's persistent memory. Appears in every session prompt." case .user: return "Notes about you. Read by the agent but not modified automatically." + case .soul: + return "Agent persona — voice, tone, personality." } } /// Resolve the remote path for this memory file on the - /// given context. `ServerContext.paths` exposes both - /// `memoryMD` and `userMD` directly. + /// given context. `ServerContext.paths` exposes + /// `memoryMD`, `userMD`, and `soulMD` directly. public func path(on context: ServerContext) -> String { switch self { case .memory: return context.paths.memoryMD case .user: return context.paths.userMD + case .soul: return context.paths.soulMD } } } diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift index c1ea858..330a89d 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift @@ -54,12 +54,22 @@ public extension ACPClient { let key = try await keyProvider() let client = try await openSSHClient(config: sshConfig, key: key) - // Command to spawn. `hermes acp` is the ACP entry point; if - // the user configured a non-default hermes binary path we - // honour that via `paths.hermesBinary`. The `exec` command - // is invoked via SSH RFC 4254 exec (no TTY) — binary-clean - // stdin/stdout for JSON-RPC bytes. - let command = context.paths.hermesBinary + " acp" + // Command to spawn. `hermes acp` is the ACP entry point. + // + // SSH `exec` (RFC 4254) runs a non-interactive, non-login shell + // whose PATH is the sshd default (`/usr/bin:/bin:/usr/sbin:/sbin`). + // Common Hermes install locations like `~/.local/bin` (pipx), + // `/opt/homebrew/bin`, and `/usr/local/bin` aren't on that PATH, + // and `-l` alone doesn't help because most users add PATH exports + // to `.zshrc` which is only sourced for interactive shells. + // + // We mirror `HermesPathSet.hermesBinaryCandidates` (the Mac-side + // local probe list) by prepending all four common locations to + // PATH before `exec`-ing hermes. Binary-clean stdio (JSON-RPC on + // stdin/stdout) is preserved because `exec` replaces the shell + // process — no intermediate layer buffers the bytes. + let hermesCmd = context.paths.hermesBinary + " acp" + let command = "PATH=\"$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH\" exec \(hermesCmd)" return try await SSHExecACPChannel( client: client, diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift index 1cb64a1..b177368 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift @@ -216,9 +216,23 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable // MARK: - Async primitives (package-private, testable through subclassing) + /// Rewrite a leading `~/` or bare `~` to the probed absolute + /// `$HOME`. SFTP (per RFC 4254 / SFTP protocol) does NOT expand + /// tildes — a path like `~/.hermes/memories/MEMORY.md` is treated + /// as a relative path with a literal `~` directory name, so every + /// SFTP op silently fails to locate the file. Normalize here before + /// handing paths to Citadel's SFTP client. + private func resolveSFTPPath(_ path: String) async throws -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + let home = try await connectionHolder.resolveHome() + if path == "~" { return home } + return home + "/" + path.dropFirst(2) + } + private func asyncReadFile(_ path: String) async throws -> Data { let sftp = try await connectionHolder.sftp() - return try await sftp.withFile(filePath: path, flags: [.read]) { file in + let resolved = try await resolveSFTPPath(path) + return try await sftp.withFile(filePath: resolved, flags: [.read]) { file in let buf = try await file.readAll() return Data(buffer: buf) } @@ -226,9 +240,10 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncWriteFile(_ path: String, data: Data) async throws { let sftp = try await connectionHolder.sftp() + let resolved = try await resolveSFTPPath(path) let byteBuffer = ByteBuffer(bytes: data) try await sftp.withFile( - filePath: path, + filePath: resolved, flags: [.write, .create, .truncate] ) { file in try await file.write(byteBuffer, at: 0) @@ -237,8 +252,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncFileExists(_ path: String) async throws -> Bool { let sftp = try await connectionHolder.sftp() + let resolved = try await resolveSFTPPath(path) do { - _ = try await sftp.getAttributes(at: path) + _ = try await sftp.getAttributes(at: resolved) return true } catch { return false @@ -247,8 +263,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncStat(_ path: String) async throws -> FileStat? { let sftp = try await connectionHolder.sftp() + let resolved = try await resolveSFTPPath(path) do { - let attrs = try await sftp.getAttributes(at: path) + let attrs = try await sftp.getAttributes(at: resolved) let size = attrs.size.map { Int64($0) } ?? 0 let mtime = attrs.accessModificationTime?.modificationTime ?? Date(timeIntervalSince1970: 0) // SFTPFileAttributes doesn't expose a "type" field directly; @@ -265,7 +282,8 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncListDirectory(_ path: String) async throws -> [String] { let sftp = try await connectionHolder.sftp() - let listing = try await sftp.listDirectory(atPath: path) + let resolved = try await resolveSFTPPath(path) + let listing = try await sftp.listDirectory(atPath: resolved) // Flatten all components across the response batches, strip the // conventional "." / ".." entries to match // `FileManager.contentsOfDirectory` behaviour. @@ -275,12 +293,13 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncCreateDirectory(_ path: String) async throws { let sftp = try await connectionHolder.sftp() + let resolved = try await resolveSFTPPath(path) // `createDirectory` at Citadel layer fails if the dir exists; // we want mkdir -p semantics so we walk the path and create // each component. Absolute paths only — the iOS app never // passes a relative path. - let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - var cursor = path.hasPrefix("/") ? "" : "" + let components = resolved.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + var cursor = resolved.hasPrefix("/") ? "" : "" for component in components { cursor += "/" + component do { @@ -299,10 +318,11 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable private func asyncRemoveFile(_ path: String) async throws { let sftp = try await connectionHolder.sftp() + let resolved = try await resolveSFTPPath(path) // Parallel to LocalTransport: no-op if the file doesn't exist. - let exists = try await asyncFileExists(path) + let exists = try await asyncFileExists(resolved) if !exists { return } - try await sftp.remove(at: path) + try await sftp.remove(at: resolved) } private func asyncRunProcess( @@ -449,6 +469,12 @@ private actor ConnectionHolder { private var sshClient: SSHClient? private var sftpClient: SFTPClient? + /// Resolved absolute `$HOME` on the remote host. Probed once per + /// connection via `echo $HOME` over SSH exec, then memoized. Used + /// to rewrite `~/…` SFTP paths (SFTP does NOT expand tildes — it + /// treats them as literal characters, so `~/.hermes/…` reads fail + /// unless we rewrite to the absolute path client-side). + private var resolvedHome: String? init( contextID: ServerID, @@ -460,6 +486,22 @@ private actor ConnectionHolder { self.keyProvider = keyProvider } + /// Probe + cache the remote user's home directory. Returns the + /// absolute path (e.g. `/Users/alan`). Falls back to the original + /// tilde-form on probe failure so callers get a best-effort path + /// rather than a hard error; those callers will surface the real + /// failure via the subsequent SFTP op. + func resolveHome() async throws -> String { + if let cached = resolvedHome { return cached } + let client = try await ssh() + let buffer = try await client.executeCommand("echo $HOME") + let raw = buffer.getString(at: 0, length: buffer.readableBytes) ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let home = trimmed.isEmpty ? "~" : trimmed + resolvedHome = home + return home + } + func ssh() async throws -> SSHClient { if let existing = sshClient, existing.isConnected { return existing diff --git a/scarf/Scarf iOS/Memory/MemoryListView.swift b/scarf/Scarf iOS/Memory/MemoryListView.swift index f2a87f9..de918fd 100644 --- a/scarf/Scarf iOS/Memory/MemoryListView.swift +++ b/scarf/Scarf iOS/Memory/MemoryListView.swift @@ -1,10 +1,12 @@ import SwiftUI import ScarfCore -/// Entry screen for the Memory feature. Two rows: MEMORY.md and -/// USER.md. Each taps into `MemoryEditorView`. Pure SwiftUI — the -/// actual load/save happens in `IOSMemoryViewModel` which lives in -/// ScarfCore and is tested on Linux. +/// Entry screen for the Memory feature. Three rows: MEMORY.md, +/// USER.md, and SOUL.md (persona). SOUL lives in the Personalities +/// feature on macOS; we fold it in here on iOS so the whole +/// "agent prompt inputs" surface is one tap away. Each row taps into +/// `MemoryEditorView`. Pure SwiftUI — the actual load/save happens in +/// `IOSMemoryViewModel` which lives in ScarfCore. struct MemoryListView: View { let config: IOSServerConfig @@ -18,8 +20,9 @@ struct MemoryListView: View { Section { memoryRow(.memory, context: ctx) memoryRow(.user, context: ctx) + memoryRow(.soul, context: ctx) } footer: { - Text("These files live under `~/.hermes/memories/` on the remote host.") + Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.") .font(.caption) } } diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index 11c402b..6285c33 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -527,7 +527,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Scarf iOS/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile"; + INFOPLIST_KEY_CFBundleDisplayName = ScarfGo; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -540,7 +540,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.3.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app"; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -565,7 +565,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Scarf iOS/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile"; + INFOPLIST_KEY_CFBundleDisplayName = ScarfGo; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -578,7 +578,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.3.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app"; + PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES;