diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7f889..5ce3243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## v3.0.0 + +### Added +- User-level memory layer at `~/.orrery/user/memory/`. Cross-project, cross-env + personal memory served via: + - SessionStart hooks installed automatically into each env's Claude / Codex / + Gemini config (controlled by per-env `shareUserMemory`, default enabled). + - MCP tools `orrery_user_memory_read` and `orrery_user_memory_write`. + - New CLI subcommands `orrery memory user info / path / emit / export / + enable / disable`. +- Wizard question on env creation: "Enable user memory?" (default: yes). +- `--no-user-memory` flag on `orrery create` to opt out from the CLI. + +### Changed +- **BREAKING:** `orrery memory ` renamed to + `orrery memory project `. No aliases. + Scripts must be updated. +- Interactive `orrery memory` now lists both project and user memory states and + routes into the relevant submenu. + +### Internal +- Introduced `MemoryStore` value type to share read/write/fragment logic + between project- and user-level memory. +- New `UserMemoryHookInstaller` protocol with Claude / Codex / Gemini + implementations; internal `_reconcile-user-memory-hooks` command runs on + every `orrery use`. + +### Notes +- Existing memories under `~/.orrery/shared/memory/{projectKey}/` are + untouched. +- A future `orrery memory user import` will help lift cross-project entries + out of the project layer; not in this release. +- If you use `orrery-sync`, add `~/.orrery/user/memory/fragments/` to your + watched-paths list to enable cross-machine sync of user memory. The fragment + format is identical to the project layer. + ## v2.7.0 - 2026-04-29 ### Architecture diff --git a/Sources/OrreryCore/Commands/CreateCommand.swift b/Sources/OrreryCore/Commands/CreateCommand.swift index a6c561b..b20311b 100644 --- a/Sources/OrreryCore/Commands/CreateCommand.swift +++ b/Sources/OrreryCore/Commands/CreateCommand.swift @@ -29,6 +29,9 @@ public struct CreateCommand: ParsableCommand { @Flag(name: .long, help: ArgumentHelp(L10n.Create.isolateMemoryHelp)) public var isolateMemory: Bool = false + @Flag(name: .long, inversion: .prefixedNo, help: ArgumentHelp(L10n.Create.userMemoryDisableHelp)) + public var userMemory: Bool = true + public init() {} public func run() throws { @@ -49,6 +52,7 @@ public struct CreateCommand: ParsableCommand { // "yes"). Same per-step flag overrides apply. var configs: [ToolSetupRunner.Config] var installStatusline = false + let shareUserMemoryDefault: Bool if let toolFlag = tool { guard let t = Tool(rawValue: toolFlag) else { throw ValidationError(L10n.Create.unknownTool(toolFlag)) @@ -61,8 +65,17 @@ public struct CreateCommand: ParsableCommand { isolateSessionsOverride: isolateSessions, isolateMemoryOverride: isolateMemory )] + // Explicit-tool path skips the interactive wizard, so the user-memory + // preference comes from the (default-true) --user-memory/--no-user-memory flag. + shareUserMemoryDefault = userMemory } else { - (configs, installStatusline) = Self.runWizard(store: store) + let wizardResult = Self.runWizard(store: store) + configs = wizardResult.0 + installStatusline = wizardResult.1 + // --no-user-memory takes precedence over the wizard answer: if the + // user explicitly passed --no-user-memory (userMemory == false), + // honor that; otherwise use whatever the wizard returned. + shareUserMemoryDefault = userMemory ? wizardResult.2 : false } // Create empty env — per-tool flags populated during apply() @@ -105,13 +118,23 @@ public struct CreateCommand: ParsableCommand { print("Could not install statusline: \(error.localizedDescription)") } } + + // Persist the resolved shareUserMemory flag on the saved env and + // install user-memory hooks when enabled. + var saved = try store.load(named: name) + saved.shareUserMemory = shareUserMemoryDefault + try store.save(saved) + if shareUserMemoryDefault { + try? store.ensureUserMemoryHooks(for: name) + } } // MARK: - Wizard /// Loop through all tools, asking setup/skip and running the per-tool wizard for each "setup". - /// Returns configs and whether the user chose to install statusline (asked after Claude setup). - static func runWizard(store: EnvironmentStore) -> ([ToolSetupRunner.Config], installStatusline: Bool) { + /// Returns configs, whether the user chose to install statusline (asked after Claude setup), + /// and whether to enable the cross-project user-memory layer. + static func runWizard(store: EnvironmentStore) -> ([ToolSetupRunner.Config], installStatusline: Bool, shareUserMemory: Bool) { var configs: [ToolSetupRunner.Config] = [] var installStatusline = false for tool in Tool.allCases { @@ -121,7 +144,8 @@ public struct CreateCommand: ParsableCommand { installStatusline = askInstallStatusline() } } - return (configs, installStatusline) + let shareUserMemory = askShareUserMemory() + return (configs, installStatusline, shareUserMemory) } static func askInstallStatusline() -> Bool { @@ -133,6 +157,15 @@ public struct CreateCommand: ParsableCommand { return selector.run() == 0 } + static func askShareUserMemory() -> Bool { + let selector = SingleSelect( + title: L10n.Create.askShareUserMemory, + options: [L10n.Create.shareUserMemoryYes, L10n.Create.shareUserMemoryNo], + selected: 0 + ) + return selector.run() == 0 + } + static func askSetupTool(_ toolName: String, defaultYes: Bool) -> Bool { let selector = SingleSelect( title: L10n.Create.askSetupTool(toolName), @@ -150,13 +183,15 @@ public struct CreateCommand: ParsableCommand { tool: Tool, isolateSessions: Bool = false, isolateMemory: Bool = false, + shareUserMemory: Bool = true, store: EnvironmentStore ) throws { let env = OrreryEnvironment( name: name, description: description, isolatedSessionTools: isolateSessions ? [tool] : [], - isolateMemory: isolateMemory + isolateMemory: isolateMemory, + shareUserMemory: shareUserMemory ) try store.save(env) try store.addTool(tool, to: name) @@ -167,5 +202,9 @@ public struct CreateCommand: ParsableCommand { let claudeConfigDir = store.toolConfigDir(tool: .claude, environment: name) store.linkOrreryMemory(projectKey: projectKey, envName: name, claudeConfigDir: claudeConfigDir) } + + if shareUserMemory { + try store.ensureUserMemoryHooks(for: name) + } } } diff --git a/Sources/OrreryCore/Commands/MemoryCommand.swift b/Sources/OrreryCore/Commands/MemoryCommand.swift index 814bff2..7f7885a 100644 --- a/Sources/OrreryCore/Commands/MemoryCommand.swift +++ b/Sources/OrreryCore/Commands/MemoryCommand.swift @@ -5,7 +5,7 @@ public struct MemoryCommand: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "memory", abstract: L10n.Memory.abstract, - subcommands: [InfoSubcommand.self, ExportSubcommand.self, IsolateSubcommand.self, ShareSubcommand.self, StorageSubcommand.self] + subcommands: [ProjectSubcommand.self, UserMemoryCommand.self] ) public init() {} @@ -15,172 +15,249 @@ public struct MemoryCommand: ParsableCommand { let projectKey = FileManager.default.currentDirectoryPath .replacingOccurrences(of: "/", with: "-") let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] - let memoryDir = store.memoryDir(projectKey: projectKey, envName: envName) - - let isIsolated: Bool - let storagePath: String? - if let envName, envName == ReservedEnvironment.defaultName { - let config = store.loadOriginConfig() - isIsolated = config.isolateMemory - storagePath = config.memoryStoragePath - } else if let envName, let env = try? store.load(named: envName) { - isIsolated = env.isolateMemory - storagePath = env.memoryStoragePath - } else { - isIsolated = false - storagePath = nil - } - // Show current status - print(L10n.Memory.statusMode(isIsolated)) - print(L10n.Memory.statusPath(memoryDir.path)) - print(L10n.Memory.storageStatus(storagePath)) - print("") + let projectDir = store.memoryDir(projectKey: projectKey, envName: envName) + let userDir = store.userMemoryDir() + let userEnabled = currentEnvShareUserMemory(store: store, envName: envName) - // Build action menu based on current state - var options: [String] = [L10n.Memory.actionInfo, L10n.Memory.actionExport] - var canToggle = false - var canStorage = false - if envName != nil { - options.append(isIsolated ? L10n.Memory.actionShare : L10n.Memory.actionIsolate) - options.append(L10n.Memory.actionStorage) - canToggle = true - canStorage = true - } - - let selector = SingleSelect(title: L10n.Memory.settingsPrompt, options: options, selected: 0) - let choice = selector.run() + print(L10n.Memory.summaryProject(projectDir.path)) + print(L10n.Memory.summaryUser(userEnabled, userDir.path)) + print("") - switch choice { + let selector = SingleSelect( + title: L10n.Memory.topLevelPrompt, + options: [L10n.Memory.manageProject, L10n.Memory.manageUser], + selected: 0 + ) + switch selector.run() { case 0: - var info = InfoSubcommand() - try info.run() + var p = MemoryCommand.ProjectSubcommand() + try p.run() case 1: - var export = ExportSubcommand() - try export.run() - case 2 where canToggle: - if isIsolated { - var share = ShareSubcommand() - try share.run() - } else { - var isolate = IsolateSubcommand() - try isolate.run() - } - case 3 where canStorage: - var storage = StorageSubcommand() - try storage.run() + var u = UserMemoryCommand() + try u.run() default: break } } - // MARK: - Info + // MARK: - Project subgroup - public struct InfoSubcommand: ParsableCommand { + public struct ProjectSubcommand: ParsableCommand { public static let configuration = CommandConfiguration( - commandName: "info", - abstract: L10n.Memory.infoAbstract + commandName: "project", + abstract: L10n.Memory.abstract, + subcommands: [ + InfoSubcommand.self, + ExportSubcommand.self, + IsolateSubcommand.self, + ShareSubcommand.self, + StorageSubcommand.self, + ] ) - @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") - public var environment: String? - public init() {} public func run() throws { let store = EnvironmentStore.default let projectKey = FileManager.default.currentDirectoryPath .replacingOccurrences(of: "/", with: "-") - let envName = environment ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] let memoryDir = store.memoryDir(projectKey: projectKey, envName: envName) - let memoryFile = memoryDir.appendingPathComponent("MEMORY.md") let isIsolated: Bool - if let envName, envName != ReservedEnvironment.defaultName, - let env = try? store.load(named: envName) { + let storagePath: String? + if let envName, envName == ReservedEnvironment.defaultName { + let config = store.loadOriginConfig() + isIsolated = config.isolateMemory + storagePath = config.memoryStoragePath + } else if let envName, let env = try? store.load(named: envName) { isIsolated = env.isolateMemory + storagePath = env.memoryStoragePath } else { isIsolated = false + storagePath = nil } - let fm = FileManager.default - let exists = fm.fileExists(atPath: memoryFile.path) - let size = (try? fm.attributesOfItem(atPath: memoryFile.path)[.size] as? Int) ?? 0 - + // Show current status print(L10n.Memory.statusMode(isIsolated)) print(L10n.Memory.statusPath(memoryDir.path)) - print(L10n.Memory.statusExists(exists, size)) - } - } + print(L10n.Memory.storageStatus(storagePath)) + print("") - // MARK: - Export + // Build action menu based on current state + var options: [String] = [L10n.Memory.actionInfo, L10n.Memory.actionExport] + var canToggle = false + var canStorage = false + if envName != nil { + options.append(isIsolated ? L10n.Memory.actionShare : L10n.Memory.actionIsolate) + options.append(L10n.Memory.actionStorage) + canToggle = true + canStorage = true + } - public struct ExportSubcommand: ParsableCommand { - public static let configuration = CommandConfiguration( - commandName: "export", - abstract: L10n.Memory.exportAbstract - ) + let selector = SingleSelect(title: L10n.Memory.settingsPrompt, options: options, selected: 0) + let choice = selector.run() + + switch choice { + case 0: + var info = InfoSubcommand() + try info.run() + case 1: + var export = ExportSubcommand() + try export.run() + case 2 where canToggle: + if isIsolated { + var share = ShareSubcommand() + try share.run() + } else { + var isolate = IsolateSubcommand() + try isolate.run() + } + case 3 where canStorage: + var storage = StorageSubcommand() + try storage.run() + default: + break + } + } - @Option(name: .shortAndLong, help: ArgumentHelp(L10n.Memory.outputHelp)) - public var output: String? + // MARK: - Info - public init() {} + public struct InfoSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "info", + abstract: L10n.Memory.infoAbstract + ) - public func run() throws { - let store = EnvironmentStore.default - let projectKey = FileManager.default.currentDirectoryPath - .replacingOccurrences(of: "/", with: "-") - let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] - let memoryFile = store.memoryDir(projectKey: projectKey, envName: envName) - .appendingPathComponent("MEMORY.md") + @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") + public var environment: String? + + public init() {} + + public func run() throws { + let store = EnvironmentStore.default + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + let envName = environment ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + let memoryDir = store.memoryDir(projectKey: projectKey, envName: envName) + let memoryFile = memoryDir.appendingPathComponent("MEMORY.md") + + let isIsolated: Bool + if let envName, envName != ReservedEnvironment.defaultName, + let env = try? store.load(named: envName) { + isIsolated = env.isolateMemory + } else { + isIsolated = false + } - guard FileManager.default.fileExists(atPath: memoryFile.path) else { - print(L10n.Memory.noMemory) - return - } + let fm = FileManager.default + let exists = fm.fileExists(atPath: memoryFile.path) + let size = (try? fm.attributesOfItem(atPath: memoryFile.path)[.size] as? Int) ?? 0 - let content = try String(contentsOf: memoryFile, encoding: .utf8) - let outputPath = output ?? "MEMORY.md" - let outputURL = URL(fileURLWithPath: outputPath) - try content.write(to: outputURL, atomically: true, encoding: .utf8) - print(L10n.Memory.exported(outputURL.path)) + print(L10n.Memory.statusMode(isIsolated)) + print(L10n.Memory.statusPath(memoryDir.path)) + print(L10n.Memory.statusExists(exists, size)) + } } - } - // MARK: - Isolate + // MARK: - Export - public struct IsolateSubcommand: ParsableCommand { - public static let configuration = CommandConfiguration( - commandName: "isolate", - abstract: L10n.Memory.isolateAbstract - ) + public struct ExportSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "export", + abstract: L10n.Memory.exportAbstract + ) - @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") - public var environment: String? + @Option(name: .shortAndLong, help: ArgumentHelp(L10n.Memory.outputHelp)) + public var output: String? - public init() {} + public init() {} - public func run() throws { - let envName = environment - ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] - guard let envName else { - throw ValidationError(L10n.Memory.noActiveEnv) + public func run() throws { + let store = EnvironmentStore.default + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + let memoryFile = store.memoryDir(projectKey: projectKey, envName: envName) + .appendingPathComponent("MEMORY.md") + + guard FileManager.default.fileExists(atPath: memoryFile.path) else { + print(L10n.Memory.noMemory) + return + } + + let content = try String(contentsOf: memoryFile, encoding: .utf8) + let outputPath = output ?? "MEMORY.md" + let outputURL = URL(fileURLWithPath: outputPath) + try content.write(to: outputURL, atomically: true, encoding: .utf8) + print(L10n.Memory.exported(outputURL.path)) } + } - let store = EnvironmentStore.default - let projectKey = FileManager.default.currentDirectoryPath - .replacingOccurrences(of: "/", with: "-") + // MARK: - Isolate - if envName == ReservedEnvironment.defaultName { - var config = store.loadOriginConfig() - guard !config.isolateMemory else { + public struct IsolateSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "isolate", + abstract: L10n.Memory.isolateAbstract + ) + + @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") + public var environment: String? + + public init() {} + + public func run() throws { + let envName = environment + ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + guard let envName else { + throw ValidationError(L10n.Memory.noActiveEnv) + } + + let store = EnvironmentStore.default + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + + if envName == ReservedEnvironment.defaultName { + var config = store.loadOriginConfig() + guard !config.isolateMemory else { + print(L10n.Memory.alreadyIsolated) + return + } + let sharedDir = store.sharedMemoryDir(projectKey: projectKey) + let isolatedDir = store.isolatedMemoryDir(projectKey: projectKey, envName: envName) + print(L10n.Memory.migrationWarning(sharedDir.path, isolatedDir.path)) + print("") + let choice = askMigrationChoiceToIsolated() + if choice == 1 { + stdoutWrite(L10n.Memory.discardConfirm) + let confirm = readLine()?.lowercased().trimmingCharacters(in: .whitespaces) ?? "" + guard confirm == "y" || confirm == "yes" else { + print(L10n.Memory.aborted) + return + } + } + try applyMigration(merge: choice == 0, fromDir: sharedDir, toDir: isolatedDir) + config.isolateMemory = true + try store.saveOriginConfig(config) + print(L10n.Memory.migrationDone(envName, true)) + return + } + + var env = try store.load(named: envName) + + guard !env.isolateMemory else { print(L10n.Memory.alreadyIsolated) return } - let sharedDir = store.sharedMemoryDir(projectKey: projectKey) + + let sharedDir = store.memoryDir(projectKey: projectKey, envName: nil) let isolatedDir = store.isolatedMemoryDir(projectKey: projectKey, envName: envName) + print(L10n.Memory.migrationWarning(sharedDir.path, isolatedDir.path)) print("") + let choice = askMigrationChoiceToIsolated() if choice == 1 { stdoutWrite(L10n.Memory.discardConfirm) @@ -190,78 +267,78 @@ public struct MemoryCommand: ParsableCommand { return } } + try applyMigration(merge: choice == 0, fromDir: sharedDir, toDir: isolatedDir) - config.isolateMemory = true - try store.saveOriginConfig(config) + + env.isolateMemory = true + try store.save(env) print(L10n.Memory.migrationDone(envName, true)) - return } + } - var env = try store.load(named: envName) + // MARK: - Share - guard !env.isolateMemory else { - print(L10n.Memory.alreadyIsolated) - return - } + public struct ShareSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "share", + abstract: L10n.Memory.shareAbstract + ) - let sharedDir = store.memoryDir(projectKey: projectKey, envName: nil) - let isolatedDir = store.isolatedMemoryDir(projectKey: projectKey, envName: envName) + @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") + public var environment: String? - print(L10n.Memory.migrationWarning(sharedDir.path, isolatedDir.path)) - print("") + public init() {} - let choice = askMigrationChoiceToIsolated() - if choice == 1 { - stdoutWrite(L10n.Memory.discardConfirm) - let confirm = readLine()?.lowercased().trimmingCharacters(in: .whitespaces) ?? "" - guard confirm == "y" || confirm == "yes" else { - print(L10n.Memory.aborted) - return + public func run() throws { + let envName = environment + ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + guard let envName else { + throw ValidationError(L10n.Memory.noActiveEnv) } - } - - try applyMigration(merge: choice == 0, fromDir: sharedDir, toDir: isolatedDir) - - env.isolateMemory = true - try store.save(env) - print(L10n.Memory.migrationDone(envName, true)) - } - } - - // MARK: - Share - - public struct ShareSubcommand: ParsableCommand { - public static let configuration = CommandConfiguration( - commandName: "share", - abstract: L10n.Memory.shareAbstract - ) - - @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") - public var environment: String? - public init() {} + let store = EnvironmentStore.default + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") - public func run() throws { - let envName = environment - ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] - guard let envName else { - throw ValidationError(L10n.Memory.noActiveEnv) - } + if envName == ReservedEnvironment.defaultName { + var config = store.loadOriginConfig() + guard config.isolateMemory else { + print(L10n.Memory.alreadyShared) + return + } + let isolatedDir = store.isolatedMemoryDir(projectKey: projectKey, envName: envName) + let sharedDir = store.sharedMemoryDir(projectKey: projectKey) + print(L10n.Memory.migrationWarning(isolatedDir.path, sharedDir.path)) + print("") + let choice = askMigrationChoiceToShared() + if choice == 1 { + stdoutWrite(L10n.Memory.discardConfirm) + let confirm = readLine()?.lowercased().trimmingCharacters(in: .whitespaces) ?? "" + guard confirm == "y" || confirm == "yes" else { + print(L10n.Memory.aborted) + return + } + } + try applyMigration(merge: choice == 0, fromDir: isolatedDir, toDir: sharedDir) + config.isolateMemory = false + try store.saveOriginConfig(config) + print(L10n.Memory.migrationDone(envName, false)) + return + } - let store = EnvironmentStore.default - let projectKey = FileManager.default.currentDirectoryPath - .replacingOccurrences(of: "/", with: "-") + var env = try store.load(named: envName) - if envName == ReservedEnvironment.defaultName { - var config = store.loadOriginConfig() - guard config.isolateMemory else { + guard env.isolateMemory else { print(L10n.Memory.alreadyShared) return } - let isolatedDir = store.isolatedMemoryDir(projectKey: projectKey, envName: envName) + + let isolatedDir = store.memoryDir(projectKey: projectKey, envName: envName) let sharedDir = store.sharedMemoryDir(projectKey: projectKey) + print(L10n.Memory.migrationWarning(isolatedDir.path, sharedDir.path)) print("") + let choice = askMigrationChoiceToShared() if choice == 1 { stdoutWrite(L10n.Memory.discardConfirm) @@ -271,111 +348,91 @@ public struct MemoryCommand: ParsableCommand { return } } - try applyMigration(merge: choice == 0, fromDir: isolatedDir, toDir: sharedDir) - config.isolateMemory = false - try store.saveOriginConfig(config) - print(L10n.Memory.migrationDone(envName, false)) - return - } - - var env = try store.load(named: envName) - guard env.isolateMemory else { - print(L10n.Memory.alreadyShared) - return - } - - let isolatedDir = store.memoryDir(projectKey: projectKey, envName: envName) - let sharedDir = store.sharedMemoryDir(projectKey: projectKey) - - print(L10n.Memory.migrationWarning(isolatedDir.path, sharedDir.path)) - print("") + try applyMigration(merge: choice == 0, fromDir: isolatedDir, toDir: sharedDir) - let choice = askMigrationChoiceToShared() - if choice == 1 { - stdoutWrite(L10n.Memory.discardConfirm) - let confirm = readLine()?.lowercased().trimmingCharacters(in: .whitespaces) ?? "" - guard confirm == "y" || confirm == "yes" else { - print(L10n.Memory.aborted) - return - } + env.isolateMemory = false + try store.save(env) + print(L10n.Memory.migrationDone(envName, false)) } + } - try applyMigration(merge: choice == 0, fromDir: isolatedDir, toDir: sharedDir) + // MARK: - Storage - env.isolateMemory = false - try store.save(env) - print(L10n.Memory.migrationDone(envName, false)) - } - } + public struct StorageSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "storage", + abstract: L10n.Memory.storageAbstract + ) - // MARK: - Storage + @Argument(help: ArgumentHelp(L10n.Memory.storagePathHelp)) + public var path: String? - public struct StorageSubcommand: ParsableCommand { - public static let configuration = CommandConfiguration( - commandName: "storage", - abstract: L10n.Memory.storageAbstract - ) + @Flag(name: .long, help: ArgumentHelp(L10n.Memory.storageResetHelp)) + public var reset: Bool = false - @Argument(help: ArgumentHelp(L10n.Memory.storagePathHelp)) - public var path: String? + @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") + public var environment: String? - @Flag(name: .long, help: ArgumentHelp(L10n.Memory.storageResetHelp)) - public var reset: Bool = false + public init() {} - @Option(name: .shortAndLong, help: "Environment name (defaults to ORRERY_ACTIVE_ENV)") - public var environment: String? + public func run() throws { + let envName = environment + ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + guard let envName else { + throw ValidationError(L10n.Memory.noActiveEnv) + } - public init() {} + let store = EnvironmentStore.default - public func run() throws { - let envName = environment - ?? ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] - guard let envName else { - throw ValidationError(L10n.Memory.noActiveEnv) - } + if envName == ReservedEnvironment.defaultName { + var config = store.loadOriginConfig() + if reset { + config.memoryStoragePath = nil + try store.saveOriginConfig(config) + print(L10n.Memory.storageReset) + return + } + guard let path else { + print(L10n.Memory.storageStatus(config.memoryStoragePath)) + return + } + try applyStoragePath(path, currentEnvName: envName, store: store, + getPath: { config.memoryStoragePath }, + setPath: { config.memoryStoragePath = $0; try store.saveOriginConfig(config) }) + return + } - let store = EnvironmentStore.default + var env = try store.load(named: envName) - if envName == ReservedEnvironment.defaultName { - var config = store.loadOriginConfig() if reset { - config.memoryStoragePath = nil - try store.saveOriginConfig(config) + env.memoryStoragePath = nil + try store.save(env) print(L10n.Memory.storageReset) return } + guard let path else { - print(L10n.Memory.storageStatus(config.memoryStoragePath)) + print(L10n.Memory.storageStatus(env.memoryStoragePath)) return } - try applyStoragePath(path, currentEnvName: envName, store: store, - getPath: { config.memoryStoragePath }, - setPath: { config.memoryStoragePath = $0; try store.saveOriginConfig(config) }) - return - } - - var env = try store.load(named: envName) - if reset { - env.memoryStoragePath = nil - try store.save(env) - print(L10n.Memory.storageReset) - return - } - - guard let path else { - print(L10n.Memory.storageStatus(env.memoryStoragePath)) - return + try applyStoragePath(path, currentEnvName: envName, store: store, + getPath: { env.memoryStoragePath }, + setPath: { env.memoryStoragePath = $0; try store.save(env) }) } - - try applyStoragePath(path, currentEnvName: envName, store: store, - getPath: { env.memoryStoragePath }, - setPath: { env.memoryStoragePath = $0; try store.save(env) }) } } } +private func currentEnvShareUserMemory(store: EnvironmentStore, envName: String?) -> Bool { + guard let envName else { return true } + if envName == ReservedEnvironment.defaultName { + return store.loadOriginConfig().shareUserMemory + } + return (try? store.load(named: envName))?.shareUserMemory ?? true +} + private func applyStoragePath( _ path: String, currentEnvName: String, diff --git a/Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift b/Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift new file mode 100644 index 0000000..7fcecdb --- /dev/null +++ b/Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift @@ -0,0 +1,34 @@ +import ArgumentParser +import Foundation + +/// Internal: reconcile each tool's settings.json so the SessionStart hook +/// matches the current env's `shareUserMemory` flag. Called from the shell +/// `use` function after the env vars are exported. +public struct ReconcileUserMemoryHooksCommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "_reconcile-user-memory-hooks", + abstract: "Internal: ensure user-memory SessionStart hooks match the active env's shareUserMemory state.", + shouldDisplay: false + ) + + public init() {} + + public func run() throws { + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + ?? ReservedEnvironment.defaultName + let store = EnvironmentStore.default + + let share: Bool + if envName == ReservedEnvironment.defaultName { + share = store.loadOriginConfig().shareUserMemory + } else { + share = (try? store.load(named: envName))?.shareUserMemory ?? true + } + + if share { + try? store.ensureUserMemoryHooks(for: envName) + } else { + try? store.removeUserMemoryHooks(for: envName) + } + } +} diff --git a/Sources/OrreryCore/Commands/SetupCommand.swift b/Sources/OrreryCore/Commands/SetupCommand.swift index 4ebef9a..e06521e 100644 --- a/Sources/OrreryCore/Commands/SetupCommand.swift +++ b/Sources/OrreryCore/Commands/SetupCommand.swift @@ -164,7 +164,20 @@ public struct SetupCommand: ParsableCommand { } } + // --- User memory (cross-project) — asked once for the whole origin --- + let userMemoryPicker = SingleSelect( + title: L10n.Create.askShareUserMemory, + options: [L10n.Create.shareUserMemoryYes, L10n.Create.shareUserMemoryNo], + selected: 0 // default: enable (recommended) + ) + config.shareUserMemory = userMemoryPicker.run() == 0 + try? store.saveOriginConfig(config) + + // Install user-memory hooks for the origin's managed tools. + // ensureUserMemoryHooks gates on config.shareUserMemory internally, + // so this is a no-op when the user opted out. + try? store.ensureUserMemoryHooks(for: ReservedEnvironment.defaultName) } static func installShellIntegration(to url: URL, activatePath: String) { diff --git a/Sources/OrreryCore/Commands/UserMemoryCommand.swift b/Sources/OrreryCore/Commands/UserMemoryCommand.swift new file mode 100644 index 0000000..8b89453 --- /dev/null +++ b/Sources/OrreryCore/Commands/UserMemoryCommand.swift @@ -0,0 +1,202 @@ +import ArgumentParser +import Foundation + +public struct UserMemoryCommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "user", + abstract: L10n.UserMemory.abstract, + subcommands: [ + InfoSubcommand.self, + PathSubcommand.self, + EmitSubcommand.self, + ExportSubcommand.self, + EnableSubcommand.self, + DisableSubcommand.self, + ] + ) + + public init() {} + + public func run() throws { + let store = EnvironmentStore.default + let dir = store.userMemoryDir() + let memoryFile = dir.appendingPathComponent("MEMORY.md") + let fm = FileManager.default + let exists = fm.fileExists(atPath: memoryFile.path) + let size = (try? fm.attributesOfItem(atPath: memoryFile.path)[.size] as? Int) ?? 0 + + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + let enabled: Bool = { + guard let envName else { return true } + if envName == ReservedEnvironment.defaultName { + return store.loadOriginConfig().shareUserMemory + } + return (try? store.load(named: envName))?.shareUserMemory ?? true + }() + + print(L10n.UserMemory.statusPath(dir.path)) + print(L10n.UserMemory.statusExists(exists, size)) + print(L10n.UserMemory.enabledInEnv(enabled)) + print("") + + let selector = SingleSelect( + title: L10n.UserMemory.actionPrompt, + options: [ + L10n.UserMemory.actionInfo, + L10n.UserMemory.actionEnable, + L10n.UserMemory.actionDisable, + L10n.UserMemory.actionExport, + ], + selected: 0 + ) + switch selector.run() { + case 0: + var i = InfoSubcommand() + try i.run() + case 1: + var e = EnableSubcommand() + try e.run() + case 2: + var d = DisableSubcommand() + try d.run() + case 3: + var x = ExportSubcommand() + try x.run() + default: + break + } + } + + /// Pure helper used by tests and EmitSubcommand. Returns what would be printed + /// to stdout by `orrery memory user emit`. Capped at 25_600 bytes. + public static func emit(store: EnvironmentStore) throws -> String { + let dir = store.userMemoryDir() + let memStore = MemoryStore(directory: dir) + return try memStore.emit(maxBytes: 25_600) + } + + /// Set `shareUserMemory = true` for `envName` and install hooks. + public static func applyEnable(envName: String, store: EnvironmentStore) throws { + if envName == ReservedEnvironment.defaultName { + var c = store.loadOriginConfig() + c.shareUserMemory = true + try store.saveOriginConfig(c) + } else { + var env = try store.load(named: envName) + env.shareUserMemory = true + try store.save(env) + } + try store.ensureUserMemoryHooks(for: envName) + } + + /// Set `shareUserMemory = false` for `envName` and remove hooks. + public static func applyDisable(envName: String, store: EnvironmentStore) throws { + if envName == ReservedEnvironment.defaultName { + var c = store.loadOriginConfig() + c.shareUserMemory = false + try store.saveOriginConfig(c) + } else { + var env = try store.load(named: envName) + env.shareUserMemory = false + try store.save(env) + } + try store.removeUserMemoryHooks(for: envName) + } + + public struct InfoSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "info", + abstract: L10n.UserMemory.infoAbstract + ) + public init() {} + public func run() throws { + let store = EnvironmentStore.default + let dir = store.userMemoryDir() + let memoryFile = dir.appendingPathComponent("MEMORY.md") + let fm = FileManager.default + let exists = fm.fileExists(atPath: memoryFile.path) + let size = (try? fm.attributesOfItem(atPath: memoryFile.path)[.size] as? Int) ?? 0 + print(L10n.UserMemory.statusPath(dir.path)) + print(L10n.UserMemory.statusExists(exists, size)) + } + } + + public struct PathSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "path", + abstract: L10n.UserMemory.pathAbstract + ) + public init() {} + public func run() throws { + print(EnvironmentStore.default.userMemoryDir().path) + } + } + + public struct EmitSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "emit", + abstract: L10n.UserMemory.emitAbstract + ) + public init() {} + public func run() throws { + // Best-effort: never fail a hook. + let output = (try? UserMemoryCommand.emit(store: .default)) ?? "" + if !output.isEmpty { + print(output) + } + } + } + + public struct ExportSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "export", + abstract: L10n.UserMemory.exportAbstract + ) + @Option(name: .shortAndLong, help: ArgumentHelp(L10n.UserMemory.exportOutputHelp)) + public var output: String? + public init() {} + public func run() throws { + let store = EnvironmentStore.default + let memoryFile = store.userMemoryDir().appendingPathComponent("MEMORY.md") + guard FileManager.default.fileExists(atPath: memoryFile.path) else { + print(L10n.UserMemory.noMemory) + return + } + let content = try String(contentsOf: memoryFile, encoding: .utf8) + let outputPath = output ?? "USER_MEMORY.md" + let outputURL = URL(fileURLWithPath: outputPath) + try content.write(to: outputURL, atomically: true, encoding: .utf8) + print(L10n.UserMemory.exported(outputURL.path)) + } + } + + public struct EnableSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "enable", + abstract: L10n.UserMemory.enableAbstract + ) + public init() {} + public func run() throws { + guard let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] else { + throw ValidationError(L10n.UserMemory.noActiveEnv) + } + try UserMemoryCommand.applyEnable(envName: envName, store: .default) + print(L10n.UserMemory.enabled(envName)) + } + } + + public struct DisableSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "disable", + abstract: L10n.UserMemory.disableAbstract + ) + public init() {} + public func run() throws { + guard let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] else { + throw ValidationError(L10n.UserMemory.noActiveEnv) + } + try UserMemoryCommand.applyDisable(envName: envName, store: .default) + print(L10n.UserMemory.disabled(envName)) + } + } +} diff --git a/Sources/OrreryCore/MCP/MCPServer.swift b/Sources/OrreryCore/MCP/MCPServer.swift index 6285795..57fa12f 100644 --- a/Sources/OrreryCore/MCP/MCPServer.swift +++ b/Sources/OrreryCore/MCP/MCPServer.swift @@ -164,6 +164,34 @@ public struct MCPServer { "additionalProperties": false ] ], + [ + "name": "orrery_user_memory_read", + "description": "Read the user-global Orrery memory. This memory follows you across all projects and all environments — use it for facts about who you are (the user), cross-project preferences, and tool/account references. Always read before writing to avoid overwriting existing knowledge. If pending sync fragments are present, consolidate them into MEMORY.md and write back with append=false.", + "inputSchema": [ + "type": "object", + "properties": [String: Any](), + "additionalProperties": false + ] + ], + [ + "name": "orrery_user_memory_write", + "description": "Write or append to the user-global Orrery memory. This persists across all projects/envs. Use for: user role/preferences, cross-project feedback rules, tool/account references. Default is append; set append=false to rewrite (used after consolidating fragments).", + "inputSchema": [ + "type": "object", + "properties": [ + "content": [ + "type": "string", + "description": "Markdown content to write to user-global memory" + ], + "append": [ + "type": "boolean", + "description": "If true, append to existing memory. If false, overwrite. Default: true" + ] + ], + "required": ["content"], + "additionalProperties": false + ] + ], ] return builtInTools + registeredToolSchemas() @@ -211,6 +239,16 @@ public struct MCPServer { let append = arguments["append"] as? Bool ?? true return writeMemory(content: content, append: append) + case "orrery_user_memory_read": + return readUserMemory() + + case "orrery_user_memory_write": + guard let content = arguments["content"] as? String else { + return toolError("Missing required parameter: content") + } + let append = arguments["append"] as? Bool ?? true + return writeUserMemory(content: content, append: append) + default: if let handler = registeredHandler(for: name) { return await handler(arguments) @@ -286,54 +324,53 @@ public struct MCPServer { return EnvironmentStore.default.memoryDir(projectKey: projectKey, envName: envName) } - private static func sharedMemoryFile() -> URL { - sharedMemoryDirectory().appendingPathComponent("MEMORY.md") + private static func projectMemoryStore() -> MemoryStore { + MemoryStore(directory: sharedMemoryDirectory()) } - private static func fragmentsDirectory() -> URL { - sharedMemoryDirectory().appendingPathComponent("fragments") + private static func userMemoryStore() -> MemoryStore { + MemoryStore(directory: EnvironmentStore.default.userMemoryDir()) } - private static func peerName() -> String { - ProcessInfo.processInfo.hostName - .replacingOccurrences(of: ".local", with: "") - } + private static func readUserMemory() -> [String: Any] { + let store = userMemoryStore() + let result = (try? store.read()) ?? .init(memory: "", fragments: []) - private static func writeFragment(content: String, action: String) { - let dir = fragmentsDirectory() - let fm = FileManager.default - let timestamp = ISO8601DateFormatter().string(from: Date()) - let peer = peerName() - let id = UUID().uuidString.prefix(8).lowercased() - let filename = "f-\(id)-\(peer).md" - - let body = """ - --- - id: f-\(id) - peer: \(peer) - timestamp: \(timestamp) - action: \(action) - --- - - \(content) - """ + var content = result.memory + if !result.fragments.isEmpty { + content += "\n\n---\n## Pending Memory Fragments (from sync)\n" + content += "The following fragments were synced from other machines and need to be integrated.\n" + content += "Please consolidate them into the memory above, then write back with append=false.\n" + content += "After integration, the fragment files will be cleaned up automatically.\n\n" + for f in result.fragments { + content += "### \(f.filename)\n" + content += f.content + "\n\n" + } + } - do { - try fm.createDirectory(at: dir, withIntermediateDirectories: true) - try body.write(to: dir.appendingPathComponent(filename), - atomically: true, encoding: .utf8) - } catch { - log("Failed to write fragment: \(error.localizedDescription)") + if content.isEmpty { + return [ + "content": [["type": "text", "text": "(no user-global memory yet)"]], + "isError": false + ] } + return [ + "content": [["type": "text", "text": content]], + "isError": false + ] } - /// Remove all fragment files after consolidation. - private static func cleanupFragments() { - let dir = fragmentsDirectory() - let fm = FileManager.default - guard let files = try? fm.contentsOfDirectory(atPath: dir.path) else { return } - for file in files where file.hasSuffix(".md") { - try? fm.removeItem(at: dir.appendingPathComponent(file)) + private static func writeUserMemory(content: String, append: Bool) -> [String: Any] { + let store = userMemoryStore() + do { + try store.write(content: content, append: append) + let path = store.directory.appendingPathComponent("MEMORY.md").path + return [ + "content": [["type": "text", "text": "User memory updated: \(path)"]], + "isError": false + ] + } catch { + return toolError("Failed to write user memory: \(error.localizedDescription)") } } @@ -356,87 +393,41 @@ public struct MCPServer { private static func readMemory() -> [String: Any] { ensureClaudeSymlink() - let file = sharedMemoryFile() - var content = "" - if FileManager.default.fileExists(atPath: file.path), - let existing = try? String(contentsOf: file, encoding: .utf8) { - content = existing - } + let store = projectMemoryStore() + let result = (try? store.read()) ?? .init(memory: "", fragments: []) - // Check for pending fragments from other peers - let fragments = pendingFragments() - if !fragments.isEmpty { + var content = result.memory + if !result.fragments.isEmpty { content += "\n\n---\n## Pending Memory Fragments (from sync)\n" content += "The following fragments were synced from other machines and need to be integrated.\n" content += "Please consolidate them into the memory above, then write back with append=false.\n" content += "After integration, the fragment files will be cleaned up automatically.\n\n" - for fragment in fragments { - content += "### \(fragment.filename)\n" - content += fragment.content + "\n\n" + for f in result.fragments { + content += "### \(f.filename)\n" + content += f.content + "\n\n" } } if content.isEmpty { return [ - "content": [ - ["type": "text", "text": "(no shared memory yet)"] - ], + "content": [["type": "text", "text": "(no shared memory yet)"]], "isError": false ] } - return [ - "content": [ - ["type": "text", "text": content] - ], + "content": [["type": "text", "text": content]], "isError": false ] } - private struct Fragment { - let filename: String - let content: String - } - - private static func pendingFragments() -> [Fragment] { - let dir = fragmentsDirectory() - let fm = FileManager.default - guard fm.fileExists(atPath: dir.path) else { return [] } - guard let files = try? fm.contentsOfDirectory(atPath: dir.path) else { return [] } - - return files - .filter { $0.hasSuffix(".md") } - .sorted() - .compactMap { filename -> Fragment? in - let path = dir.appendingPathComponent(filename) - guard let content = try? String(contentsOf: path, encoding: .utf8) else { return nil } - return Fragment(filename: filename, content: content) - } - } - private static func writeMemory(content: String, append: Bool) -> [String: Any] { ensureClaudeSymlink() - let file = sharedMemoryFile() - let fm = FileManager.default - let dir = file.deletingLastPathComponent() + let store = projectMemoryStore() do { - try fm.createDirectory(at: dir, withIntermediateDirectories: true) - - if append && fm.fileExists(atPath: file.path) { - let existing = try String(contentsOf: file, encoding: .utf8) - try (existing + "\n" + content).write(to: file, atomically: true, encoding: .utf8) - } else { - try content.write(to: file, atomically: true, encoding: .utf8) - // Overwrite means consolidation — clean up integrated fragments - cleanupFragments() - } - - writeFragment(content: content, action: append ? "append" : "overwrite") - + try store.write(content: content, append: append) + let path = store.directory.appendingPathComponent("MEMORY.md").path return [ - "content": [ - ["type": "text", "text": "Memory updated: \(file.path)"] - ], + "content": [["type": "text", "text": "Memory updated: \(path)"]], "isError": false ] } catch { diff --git a/Sources/OrreryCore/Models/OrreryEnvironment.swift b/Sources/OrreryCore/Models/OrreryEnvironment.swift index 058400c..be58885 100644 --- a/Sources/OrreryCore/Models/OrreryEnvironment.swift +++ b/Sources/OrreryCore/Models/OrreryEnvironment.swift @@ -12,15 +12,30 @@ public struct OriginConfig: Codable, Sendable { /// Tools whose sessions are isolated (not symlinked to shared). /// Absent from the set → shared (default). public var isolatedSessionTools: Set + public var shareUserMemory: Bool public init( isolateMemory: Bool = true, memoryStoragePath: String? = nil, - isolatedSessionTools: Set = [] + isolatedSessionTools: Set = [], + shareUserMemory: Bool = true ) { self.isolateMemory = isolateMemory self.memoryStoragePath = memoryStoragePath self.isolatedSessionTools = isolatedSessionTools + self.shareUserMemory = shareUserMemory + } + + private enum CodingKeys: String, CodingKey { + case isolateMemory, memoryStoragePath, isolatedSessionTools, shareUserMemory + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + isolateMemory = try c.decodeIfPresent(Bool.self, forKey: .isolateMemory) ?? true + memoryStoragePath = try c.decodeIfPresent(String.self, forKey: .memoryStoragePath) + isolatedSessionTools = try c.decodeIfPresent(Set.self, forKey: .isolatedSessionTools) ?? [] + shareUserMemory = try c.decodeIfPresent(Bool.self, forKey: .shareUserMemory) ?? true } public func isolateSessions(for tool: Tool) -> Bool { @@ -40,6 +55,7 @@ public struct OrreryEnvironment: Codable, Sendable { /// Tools NOT in this set share sessions across envs (the default). public var isolatedSessionTools: Set public var isolateMemory: Bool + public var shareUserMemory: Bool /// Custom storage root for memory. When set, MEMORY.md and fragments/ live here /// instead of the default ~/.orrery path. Useful for external wikis (e.g. Obsidian). public var memoryStoragePath: String? @@ -54,6 +70,7 @@ public struct OrreryEnvironment: Codable, Sendable { env: [String: String] = [:], isolatedSessionTools: Set = [], isolateMemory: Bool = true, + shareUserMemory: Bool = true, memoryStoragePath: String? = nil ) { self.id = id @@ -65,6 +82,7 @@ public struct OrreryEnvironment: Codable, Sendable { self.env = env self.isolatedSessionTools = isolatedSessionTools self.isolateMemory = isolateMemory + self.shareUserMemory = shareUserMemory self.memoryStoragePath = memoryStoragePath } @@ -80,6 +98,7 @@ public struct OrreryEnvironment: Codable, Sendable { case isolatedSessionTools case isolateSessions // legacy — decode only case isolateMemory, memoryStoragePath + case shareUserMemory } public init(from decoder: Decoder) throws { @@ -102,6 +121,7 @@ public struct OrreryEnvironment: Codable, Sendable { } isolateMemory = try c.decodeIfPresent(Bool.self, forKey: .isolateMemory) ?? false + shareUserMemory = try c.decodeIfPresent(Bool.self, forKey: .shareUserMemory) ?? true memoryStoragePath = try c.decodeIfPresent(String.self, forKey: .memoryStoragePath) } @@ -116,6 +136,7 @@ public struct OrreryEnvironment: Codable, Sendable { try c.encode(env, forKey: .env) try c.encode(isolatedSessionTools, forKey: .isolatedSessionTools) try c.encode(isolateMemory, forKey: .isolateMemory) + try c.encode(shareUserMemory, forKey: .shareUserMemory) try c.encodeIfPresent(memoryStoragePath, forKey: .memoryStoragePath) } } diff --git a/Sources/OrreryCore/Resources/Localization/en.json b/Sources/OrreryCore/Resources/Localization/en.json index bca0040..1ad5cbe 100644 --- a/Sources/OrreryCore/Resources/Localization/en.json +++ b/Sources/OrreryCore/Resources/Localization/en.json @@ -64,6 +64,10 @@ "create.tools": "Tools: {list}", "create.unknownTool": "Unknown tool '{raw}'. Valid tools: claude, codex, gemini", "create.wizardTitle": "Select a tool (↑↓ move, enter confirm):", + "create.askShareUserMemory": "Enable user memory (cross-project personal memory layer)?", + "create.shareUserMemoryYes": "Enable (recommended)", + "create.shareUserMemoryNo": "Disable for this env", + "create.userMemoryDisableHelp": "Disable user memory in this env (pass --no-user-memory; default: enabled).", "current.abstract": "Print the name of the active environment", "current.noActive": "(no active environment)", "delegate.abstract": "Delegate a task to an AI tool in a specific environment", @@ -190,6 +194,35 @@ "memory.storageSet": "Memory storage set to: {path}", "memory.storageStatus.custom": "Storage: {path} (custom)", "memory.storageStatus.default": "Storage: default (~/.orrery)", + "memory.summaryProject": "Project memory: {path}", + "memory.summaryUser.enabled": "User memory: enabled ({path})", + "memory.summaryUser.disabled": "User memory: disabled ({path})", + "memory.topLevelPrompt": "What would you like to manage?", + "memory.manageProject": "Project memory", + "memory.manageUser": "User memory", + "userMemory.abstract": "Manage user-global Orrery memory (cross-project, cross-env).", + "userMemory.infoAbstract": "Show user memory location and status.", + "userMemory.pathAbstract": "Print the user memory directory path.", + "userMemory.emitAbstract": "Print MEMORY.md to stdout. Used by SessionStart hooks; not for humans.", + "userMemory.exportAbstract": "Export user MEMORY.md to a file.", + "userMemory.exportOutputHelp": "Output file path (default: USER_MEMORY.md).", + "userMemory.enableAbstract": "Enable user memory in the current env (installs hooks).", + "userMemory.disableAbstract": "Disable user memory in the current env (removes hooks).", + "userMemory.statusPath": "Path: {path}", + "userMemory.statusExists.present": "MEMORY.md exists, size: {size} bytes", + "userMemory.statusExists.absent": "MEMORY.md does not exist", + "userMemory.noMemory": "No user memory to export.", + "userMemory.exported": "Exported to {path}", + "userMemory.enabledInEnv.enabled": "Enabled in this env", + "userMemory.enabledInEnv.disabled": "Disabled in this env", + "userMemory.actionPrompt": "User memory action:", + "userMemory.actionInfo": "Info", + "userMemory.actionEnable": "Enable in this env", + "userMemory.actionDisable": "Disable in this env", + "userMemory.actionExport": "Export", + "userMemory.noActiveEnv": "No active environment (set ORRERY_ACTIVE_ENV or run inside orrery use).", + "userMemory.enabled": "User memory enabled for {env}", + "userMemory.disabled": "User memory disabled for {env}", "orrery.abstract": "AI CLI environment manager — manage accounts for Claude, Codex, Gemini", "rename.abstract": "Rename an orrery environment", "rename.nameHelp": "Current environment name", diff --git a/Sources/OrreryCore/Resources/Localization/ja.json b/Sources/OrreryCore/Resources/Localization/ja.json index b1c265c..31bf1b4 100644 --- a/Sources/OrreryCore/Resources/Localization/ja.json +++ b/Sources/OrreryCore/Resources/Localization/ja.json @@ -64,6 +64,10 @@ "create.tools": "Tools: {list}", "create.unknownTool": "Unknown tool '{raw}'. Valid tools: claude, codex, gemini", "create.wizardTitle": "Select a tool (↑↓ move, enter confirm):", + "create.askShareUserMemory": "ユーザーメモリ(プロジェクト横断の個人メモリ層)を有効にしますか?", + "create.shareUserMemoryYes": "有効にする(推奨)", + "create.shareUserMemoryNo": "この環境では無効化", + "create.userMemoryDisableHelp": "この環境でユーザーメモリを無効化(--no-user-memory を指定;デフォルト:有効)。", "current.abstract": "Print the name of the active environment", "current.noActive": "(no active environment)", "delegate.abstract": "Delegate a task to an AI tool in a specific environment", @@ -190,6 +194,35 @@ "memory.storageSet": "Memory storage set to: {path}", "memory.storageStatus.custom": "Storage: {path} (custom)", "memory.storageStatus.default": "Storage: default (~/.orrery)", + "memory.summaryProject": "プロジェクトメモリ: {path}", + "memory.summaryUser.enabled": "ユーザーメモリ: 有効 ({path})", + "memory.summaryUser.disabled": "ユーザーメモリ: 無効 ({path})", + "memory.topLevelPrompt": "どちらを管理しますか?", + "memory.manageProject": "プロジェクトメモリ", + "memory.manageUser": "ユーザーメモリ", + "userMemory.abstract": "ユーザー全体のメモリを管理します(プロジェクト・環境をまたぐ)。", + "userMemory.infoAbstract": "ユーザーメモリの場所と状態を表示します。", + "userMemory.pathAbstract": "ユーザーメモリのディレクトリパスを出力します。", + "userMemory.emitAbstract": "MEMORY.md を標準出力に出力します。SessionStart フックで使用、人間向けではありません。", + "userMemory.exportAbstract": "ユーザーの MEMORY.md をファイルに書き出します。", + "userMemory.exportOutputHelp": "出力ファイルのパス(デフォルト: USER_MEMORY.md)。", + "userMemory.enableAbstract": "この環境でユーザーメモリを有効化します(フックを設置)。", + "userMemory.disableAbstract": "この環境でユーザーメモリを無効化します(フックを削除)。", + "userMemory.statusPath": "パス: {path}", + "userMemory.statusExists.present": "MEMORY.md は存在します。サイズ: {size} bytes", + "userMemory.statusExists.absent": "MEMORY.md は存在しません", + "userMemory.noMemory": "書き出せるユーザーメモリはありません。", + "userMemory.exported": "{path} に書き出しました", + "userMemory.enabledInEnv.enabled": "この環境では有効", + "userMemory.enabledInEnv.disabled": "この環境では無効", + "userMemory.actionPrompt": "ユーザーメモリ操作:", + "userMemory.actionInfo": "情報を表示", + "userMemory.actionEnable": "この環境で有効化", + "userMemory.actionDisable": "この環境で無効化", + "userMemory.actionExport": "書き出し", + "userMemory.noActiveEnv": "アクティブな環境がありません(ORRERY_ACTIVE_ENV を設定するか orrery use 内で実行してください)。", + "userMemory.enabled": "{env} のユーザーメモリを有効化しました", + "userMemory.disabled": "{env} のユーザーメモリを無効化しました", "orrery.abstract": "AI CLI environment manager — manage accounts for Claude, Codex, Gemini", "rename.abstract": "Rename an orrery environment", "rename.nameHelp": "Current environment name", diff --git a/Sources/OrreryCore/Resources/Localization/keys.md b/Sources/OrreryCore/Resources/Localization/keys.md index 4949bba..bc13116 100644 --- a/Sources/OrreryCore/Resources/Localization/keys.md +++ b/Sources/OrreryCore/Resources/Localization/keys.md @@ -182,6 +182,36 @@ When a key has Bool/Optional branches (e.g. `memory.migrationDone.isolated` + | `memory.storageResetHelp` | `--reset` flag help. | | `memory.storageSet` | Success after setting a custom path. `{path}`. | | `memory.storageStatus.custom` / `memory.storageStatus.default` | Status row. Optional-branch on whether the path is customized. `{path}`. | +| `memory.summaryProject` | Recap line shown by the top-level `orrery memory` info / wizard intro: project memory path. `{path}` = MEMORY.md path. | +| `memory.summaryUser.enabled` / `memory.summaryUser.disabled` | Recap line shown by `orrery memory` info: user memory status for the active env. Bool branch on whether user memory is enabled. `{path}` = user MEMORY.md path. | +| `memory.topLevelPrompt` | Title for the top-level picker shown by bare `orrery memory` (project vs. user). | +| `memory.manageProject` | Top-level picker option: manage project memory. | +| `memory.manageUser` | Top-level picker option: manage user memory. | + +## userMemory — `orrery user-memory` + +User-global memory subcommand group: `orrery user-memory info / path / emit / export / enable / disable`. + +| Key | Context | +| --- | --- | +| `userMemory.abstract` | Parent command help for `orrery user-memory`. | +| `userMemory.infoAbstract` | `user-memory info` sub-command help. | +| `userMemory.pathAbstract` | `user-memory path` sub-command help. | +| `userMemory.emitAbstract` | `user-memory emit` sub-command help. Hidden / internal — used by SessionStart hooks, not by humans. | +| `userMemory.exportAbstract` | `user-memory export` sub-command help. | +| `userMemory.exportOutputHelp` | `--output` flag help for `user-memory export`. Default file name `USER_MEMORY.md` is literal. | +| `userMemory.enableAbstract` | `user-memory enable` sub-command help. | +| `userMemory.disableAbstract` | `user-memory disable` sub-command help. | +| `userMemory.statusPath` | Status row printed by `user-memory info`: path to user MEMORY.md. `{path}`. | +| `userMemory.statusExists.present` / `userMemory.statusExists.absent` | Status row from `user-memory info`. Bool branch on file existence. `{size}` = bytes (only meaningful in the `present` branch). | +| `userMemory.noMemory` | Shown by `user-memory export` when there is no user MEMORY.md to export. | +| `userMemory.exported` | Success message from `user-memory export`. `{path}` = output file path. | +| `userMemory.enabledInEnv.enabled` / `userMemory.enabledInEnv.disabled` | Status row from `user-memory info`. Bool branch on whether user memory is enabled in the active env. Both variants must read naturally — do **not** interpolate `true`/`false`. | +| `userMemory.actionPrompt` | Title for the interactive action picker shown by bare `orrery user-memory`. | +| `userMemory.actionInfo` | Action picker option: view info. | +| `userMemory.actionEnable` | Action picker option: enable in this env. | +| `userMemory.actionDisable` | Action picker option: disable in this env. | +| `userMemory.actionExport` | Action picker option: export. | ## orrery — root command diff --git a/Sources/OrreryCore/Resources/Localization/l10n-signatures.json b/Sources/OrreryCore/Resources/Localization/l10n-signatures.json index f85ab3e..9404644 100644 --- a/Sources/OrreryCore/Resources/Localization/l10n-signatures.json +++ b/Sources/OrreryCore/Resources/Localization/l10n-signatures.json @@ -494,6 +494,38 @@ "kind": "var", "parameters": [] }, + { + "path": [ + "Create", + "askShareUserMemory" + ], + "kind": "var", + "parameters": [] + }, + { + "path": [ + "Create", + "shareUserMemoryYes" + ], + "kind": "var", + "parameters": [] + }, + { + "path": [ + "Create", + "shareUserMemoryNo" + ], + "kind": "var", + "parameters": [] + }, + { + "path": [ + "Create", + "userMemoryDisableHelp" + ], + "kind": "var", + "parameters": [] + }, { "path": [ "Current", @@ -1441,6 +1473,139 @@ "absent": "default" } }, + { + "path": [ + "Memory", + "summaryProject" + ], + "kind": "func", + "parameters": [ + { + "label": "_", + "name": "path", + "type": "String" + } + ] + }, + { + "path": [ + "Memory", + "summaryUser" + ], + "kind": "func", + "parameters": [ + { + "label": "_", + "name": "enabled", + "type": "Bool" + }, + { + "label": "_", + "name": "path", + "type": "String" + } + ], + "variants": { + "selector": "enabled", + "type": "bool", + "true": "enabled", + "false": "disabled" + } + }, + { + "path": [ + "Memory", + "topLevelPrompt" + ], + "kind": "var", + "parameters": [] + }, + { + "path": [ + "Memory", + "manageProject" + ], + "kind": "var", + "parameters": [] + }, + { + "path": [ + "Memory", + "manageUser" + ], + "kind": "var", + "parameters": [] + }, + { "path": ["UserMemory", "abstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "infoAbstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "pathAbstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "emitAbstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "exportAbstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "exportOutputHelp"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "enableAbstract"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "disableAbstract"], "kind": "var", "parameters": [] }, + { + "path": ["UserMemory", "statusPath"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "path", "type": "String" } + ] + }, + { + "path": ["UserMemory", "statusExists"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "exists", "type": "Bool" }, + { "label": "_", "name": "size", "type": "Int" } + ], + "variants": { + "selector": "exists", + "type": "bool", + "true": "present", + "false": "absent" + } + }, + { "path": ["UserMemory", "noMemory"], "kind": "var", "parameters": [] }, + { + "path": ["UserMemory", "exported"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "path", "type": "String" } + ] + }, + { + "path": ["UserMemory", "enabledInEnv"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "enabled", "type": "Bool" } + ], + "variants": { + "selector": "enabled", + "type": "bool", + "true": "enabled", + "false": "disabled" + } + }, + { "path": ["UserMemory", "actionPrompt"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "actionInfo"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "actionEnable"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "actionDisable"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "actionExport"], "kind": "var", "parameters": [] }, + { "path": ["UserMemory", "noActiveEnv"], "kind": "var", "parameters": [] }, + { + "path": ["UserMemory", "enabled"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "env", "type": "String" } + ] + }, + { + "path": ["UserMemory", "disabled"], + "kind": "func", + "parameters": [ + { "label": "_", "name": "env", "type": "String" } + ] + }, { "path": [ "Orrery", diff --git a/Sources/OrreryCore/Resources/Localization/zh-Hant.json b/Sources/OrreryCore/Resources/Localization/zh-Hant.json index 9e623c0..58f678f 100644 --- a/Sources/OrreryCore/Resources/Localization/zh-Hant.json +++ b/Sources/OrreryCore/Resources/Localization/zh-Hant.json @@ -64,6 +64,10 @@ "create.tools": "工具:{list}", "create.unknownTool": "未知工具 '{raw}'。可用工具:claude, codex, gemini", "create.wizardTitle": "選擇要使用的工具(↑↓ 移動,Enter 確認):", + "create.askShareUserMemory": "是否啟用 user memory(跨專案的個人記憶層)?", + "create.shareUserMemoryYes": "啟用(推薦)", + "create.shareUserMemoryNo": "此環境停用", + "create.userMemoryDisableHelp": "在此環境停用 user memory(傳入 --no-user-memory;預設:啟用)。", "current.abstract": "顯示目前啟用的環境名稱", "current.noActive": "(無啟用的環境)", "delegate.abstract": "委派任務給指定環境的 AI 工具", @@ -190,6 +194,35 @@ "memory.storageSet": "已設定 memory 儲存路徑:{path}", "memory.storageStatus.custom": "儲存路徑:{path}(自訂)", "memory.storageStatus.default": "儲存路徑:預設(~/.orrery)", + "memory.summaryProject": "專案記憶:{path}", + "memory.summaryUser.enabled": "使用者記憶:已啟用({path})", + "memory.summaryUser.disabled": "使用者記憶:已停用({path})", + "memory.topLevelPrompt": "您想管理哪一種記憶?", + "memory.manageProject": "專案記憶", + "memory.manageUser": "使用者記憶", + "userMemory.abstract": "管理使用者層級的 Orrery 記憶(跨專案、跨環境)。", + "userMemory.infoAbstract": "顯示使用者記憶的位置與狀態。", + "userMemory.pathAbstract": "輸出使用者記憶的目錄路徑。", + "userMemory.emitAbstract": "將 MEMORY.md 印至 stdout,供 SessionStart hook 使用;非給人類閱讀。", + "userMemory.exportAbstract": "將使用者 MEMORY.md 匯出為檔案。", + "userMemory.exportOutputHelp": "輸出檔案路徑(預設:USER_MEMORY.md)。", + "userMemory.enableAbstract": "在目前環境啟用使用者記憶(安裝 hook)。", + "userMemory.disableAbstract": "在目前環境停用使用者記憶(移除 hook)。", + "userMemory.statusPath": "路徑:{path}", + "userMemory.statusExists.present": "MEMORY.md 存在,大小:{size} 位元組", + "userMemory.statusExists.absent": "MEMORY.md 不存在", + "userMemory.noMemory": "沒有可匯出的使用者記憶。", + "userMemory.exported": "已匯出至 {path}", + "userMemory.enabledInEnv.enabled": "此環境已啟用", + "userMemory.enabledInEnv.disabled": "此環境已停用", + "userMemory.actionPrompt": "使用者記憶操作:", + "userMemory.actionInfo": "查看資訊", + "userMemory.actionEnable": "在此環境啟用", + "userMemory.actionDisable": "在此環境停用", + "userMemory.actionExport": "匯出", + "userMemory.noActiveEnv": "沒有作用中的環境(請設定 ORRERY_ACTIVE_ENV 或在 orrery use 中執行)。", + "userMemory.enabled": "已為 {env} 啟用使用者記憶", + "userMemory.disabled": "已為 {env} 停用使用者記憶", "orrery.abstract": "AI CLI 環境管理工具 — 管理 Claude、Codex、Gemini 帳號", "rename.abstract": "重新命名 orrery 環境", "rename.nameHelp": "目前的環境名稱", diff --git a/Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift b/Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift new file mode 100644 index 0000000..b1c3e02 --- /dev/null +++ b/Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift @@ -0,0 +1,158 @@ +import Foundation + +public protocol UserMemoryHookInstaller { + /// Idempotently add the user-memory SessionStart hook entry to this tool's config. + func install(at configDir: URL) throws + /// Remove only entries with `_orrery_managed: true`. + func remove(at configDir: URL) throws + /// Whether the managed entry is currently present. + func isInstalled(at configDir: URL) -> Bool +} + +/// Marker key the installers stamp on every entry they manage, so `remove` can +/// tell our hooks apart from user-installed ones. +let OrreryManagedKey = "_orrery_managed" +let UserMemoryHookCommand = "orrery memory user emit" + +/// Shared JSON-merge logic used by all three installers — Claude, Codex (hooks.json), +/// Gemini all read JSON files with the same `hooks.SessionStart[*].hooks[*]` shape. +struct JSONHookEditor { + let settingsFile: URL + + func loadOrEmpty() throws -> [String: Any] { + let fm = FileManager.default + guard fm.fileExists(atPath: settingsFile.path) else { return [:] } + let data = try Data(contentsOf: settingsFile) + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + func save(_ root: [String: Any]) throws { + let fm = FileManager.default + try fm.createDirectory(at: settingsFile.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: settingsFile, options: .atomic) + } + + /// Returns `(root, sessionStart, firstMatcherIndex)` after ensuring shape exists. + func ensureSessionStartShape(in root: inout [String: Any]) -> Int { + var hooks = (root["hooks"] as? [String: Any]) ?? [:] + var sessionStart = (hooks["SessionStart"] as? [[String: Any]]) ?? [] + if sessionStart.isEmpty { + sessionStart.append(["matcher": "*", "hooks": [[String: Any]]()]) + } else if sessionStart[0]["hooks"] == nil { + sessionStart[0]["hooks"] = [[String: Any]]() + } + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + return 0 + } + + func install() throws { + var root = try loadOrEmpty() + _ = ensureSessionStartShape(in: &root) + var hooks = root["hooks"] as! [String: Any] + var sessionStart = hooks["SessionStart"] as! [[String: Any]] + var entries = sessionStart[0]["hooks"] as! [[String: Any]] + + let alreadyPresent = entries.contains { + ($0[OrreryManagedKey] as? Bool) == true && + ($0["command"] as? String) == UserMemoryHookCommand + } + if !alreadyPresent { + entries.append([ + "type": "command", + "command": UserMemoryHookCommand, + OrreryManagedKey: true + ]) + } + sessionStart[0]["hooks"] = entries + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + try save(root) + } + + func remove() throws { + var root = try loadOrEmpty() + guard var hooks = root["hooks"] as? [String: Any], + var sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return } + for i in sessionStart.indices { + if var entries = sessionStart[i]["hooks"] as? [[String: Any]] { + entries.removeAll { ($0[OrreryManagedKey] as? Bool) == true } + sessionStart[i]["hooks"] = entries + } + } + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + try save(root) + } + + func isInstalled() -> Bool { + guard let root = try? loadOrEmpty(), + let hooks = root["hooks"] as? [String: Any], + let sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return false } + for matcher in sessionStart { + let entries = (matcher["hooks"] as? [[String: Any]]) ?? [] + if entries.contains(where: { + ($0[OrreryManagedKey] as? Bool) == true && + ($0["command"] as? String) == UserMemoryHookCommand + }) { + return true + } + } + return false + } +} + +public struct ClaudeHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).isInstalled() + } +} + +public struct CodexHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).isInstalled() + } +} + +public struct GeminiHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).isInstalled() + } +} + +/// Returns the installer for `tool`. Add new tools here as they gain SessionStart support. +public func userMemoryHookInstaller(for tool: Tool) -> UserMemoryHookInstaller { + switch tool { + case .claude: return ClaudeHookInstaller() + case .codex: return CodexHookInstaller() + case .gemini: return GeminiHookInstaller() + } +} diff --git a/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift b/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift index 71a45d3..da79cdb 100644 --- a/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift +++ b/Sources/OrreryCore/Shell/ShellFunctionGenerator.swift @@ -41,12 +41,14 @@ public struct ShellFunctionGenerator { if [ "$2" = "origin" ]; then unset CLAUDE_CONFIG_DIR CODEX_HOME CODEX_CONFIG_DIR GEMINI_CONFIG_DIR ORRERY_GEMINI_HOME export ORRERY_ACTIVE_ENV="origin" + command orrery-bin _reconcile-user-memory-hooks 2>/dev/null || true command orrery-bin _set-current origin 2>/dev/null || true else local exports exports=$(command orrery-bin _export "$2") || { echo "orrery: environment '$2' not found" >&2; return 1; } eval "$exports" export ORRERY_ACTIVE_ENV="$2" + command orrery-bin _reconcile-user-memory-hooks 2>/dev/null || true command orrery-bin _set-current "$2" 2>/dev/null || true # Background quota refresh so `orrery list` shows fresh data # next time. Double subshell hides the job notice from @@ -215,6 +217,8 @@ public struct ShellFunctionGenerator { orrery use "$env_name" >/dev/null 2>&1 || true fi fi + # Reconcile user-memory SessionStart hooks for the active env. + command orrery-bin _reconcile-user-memory-hooks 2>/dev/null || true # Ensure the Orrery memory directory is linked into Claude's auto-memory location command orrery-bin _link-memory 2>/dev/null || true } diff --git a/Sources/OrreryCore/Storage/EnvironmentStore.swift b/Sources/OrreryCore/Storage/EnvironmentStore.swift index 60c6150..e1f7958 100644 --- a/Sources/OrreryCore/Storage/EnvironmentStore.swift +++ b/Sources/OrreryCore/Storage/EnvironmentStore.swift @@ -131,6 +131,10 @@ public struct EnvironmentStore: Sendable { env.tools.append(tool) try save(env) } + + if env.shareUserMemory { + try? userMemoryHookInstaller(for: tool).install(at: toolDir) + } } /// Ensures shared session symlinks exist for a tool in the given environment. @@ -263,6 +267,14 @@ public struct EnvironmentStore: Sendable { sharedMemoryDir(projectKey: projectKey).appendingPathComponent(envName) } + /// User-global memory dir: `~/.orrery/user/memory/`. + /// Independent of any env or projectKey — same path for every project, every env. + public func userMemoryDir() -> URL { + homeURL + .appendingPathComponent("user") + .appendingPathComponent("memory") + } + /// Returns the memory directory URL for the given env (nil = default/shared). /// Priority: custom memoryStoragePath > isolateMemory > shared default. /// The directory is symlinked into Claude's auto-memory dir so `MEMORY.md` + @@ -335,6 +347,46 @@ public struct EnvironmentStore: Sendable { try? fm.createSymbolicLink(at: memoryDirURL, withDestinationURL: targetDir) } + /// Install the user-memory SessionStart hook into each tool config dir of this env, + /// but only if `env.shareUserMemory == true`. Idempotent. + public func ensureUserMemoryHooks(for envName: String) throws { + let share: Bool + let tools: [Tool] + if envName == ReservedEnvironment.defaultName { + share = loadOriginConfig().shareUserMemory + tools = Tool.allCases.filter { isOriginManaged(tool: $0) } + } else { + let env = try load(named: envName) + share = env.shareUserMemory + tools = env.tools + } + guard share else { return } + for tool in tools { + let dir = (envName == ReservedEnvironment.defaultName) + ? originConfigDir(tool: tool) + : toolConfigDir(tool: tool, environment: envName) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try userMemoryHookInstaller(for: tool).install(at: dir) + } + } + + /// Remove the managed hook entry from each tool config dir of this env. + public func removeUserMemoryHooks(for envName: String) throws { + let tools: [Tool] + if envName == ReservedEnvironment.defaultName { + tools = Tool.allCases.filter { isOriginManaged(tool: $0) } + } else { + tools = (try load(named: envName)).tools + } + for tool in tools { + let dir = (envName == ReservedEnvironment.defaultName) + ? originConfigDir(tool: tool) + : toolConfigDir(tool: tool, environment: envName) + guard FileManager.default.fileExists(atPath: dir.path) else { continue } + try userMemoryHookInstaller(for: tool).remove(at: dir) + } + } + // MARK: - Origin management /// Storage directory for origin tool configs: `~/.orrery/origin/` diff --git a/Sources/OrreryCore/Storage/MemoryStore.swift b/Sources/OrreryCore/Storage/MemoryStore.swift new file mode 100644 index 0000000..e51a868 --- /dev/null +++ b/Sources/OrreryCore/Storage/MemoryStore.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Reads / writes a markdown memory store at `directory/`. +/// +/// Layout: +/// - `directory/MEMORY.md` — canonical index, what the AI agent reads/writes. +/// - `directory/fragments/f-{id}-{peer}.md` — per-write fragment for cross-machine sync. +/// +/// Used by both the project-level (per `projectKey` / env) and user-level +/// (`~/.orrery/user/memory/`) memory layers. The two layers differ only in +/// which `directory` they point at. +public struct MemoryStore: Sendable { + public let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + public struct Fragment: Sendable, Equatable { + public let filename: String + public let content: String + } + + public struct ReadResult: Sendable { + public let memory: String + public let fragments: [Fragment] + } + + private var memoryFile: URL { directory.appendingPathComponent("MEMORY.md") } + private var fragmentsDir: URL { directory.appendingPathComponent("fragments") } + + /// Read `MEMORY.md` plus any pending fragments. Both default to empty when missing. + public func read() throws -> ReadResult { + let fm = FileManager.default + var memory = "" + if fm.fileExists(atPath: memoryFile.path) { + memory = try String(contentsOf: memoryFile, encoding: .utf8) + } + + var fragments: [Fragment] = [] + if fm.fileExists(atPath: fragmentsDir.path) { + let names = (try? fm.contentsOfDirectory(atPath: fragmentsDir.path)) ?? [] + for name in names.sorted() where name.hasSuffix(".md") { + let url = fragmentsDir.appendingPathComponent(name) + if let body = try? String(contentsOf: url, encoding: .utf8) { + fragments.append(Fragment(filename: name, content: body)) + } + } + } + return ReadResult(memory: memory, fragments: fragments) + } + + /// Write or append to `MEMORY.md`, and record a fragment of the same write. + /// When `append == false`, cleans up *prior* fragments before recording the new one — this + /// is the consolidation contract: an overwrite means "the agent has integrated everything". + public func write(content: String, append: Bool) throws { + let fm = FileManager.default + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + + if append, fm.fileExists(atPath: memoryFile.path) { + let existing = try String(contentsOf: memoryFile, encoding: .utf8) + try (existing + "\n" + content).write(to: memoryFile, atomically: true, encoding: .utf8) + } else { + try content.write(to: memoryFile, atomically: true, encoding: .utf8) + cleanupFragments() + } + + try writeFragment(content: content, action: append ? "append" : "overwrite") + } + + /// Remove all fragments from `fragments/`. Best-effort; missing dir is fine. + public func cleanupFragments() { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: fragmentsDir.path) else { return } + for name in names where name.hasSuffix(".md") { + try? fm.removeItem(at: fragmentsDir.appendingPathComponent(name)) + } + } + + /// Produce the hook-stdout / read-tool output: MEMORY.md content optionally followed + /// by a "Pending Memory Fragments" block, truncated to `maxBytes`. + public func emit(maxBytes: Int) throws -> String { + let r = try read() + var output = r.memory + if !r.fragments.isEmpty { + output += "\n\n---\n## Pending Memory Fragments (from sync)\n" + output += "The following fragments were synced from other machines and need to be integrated.\n" + output += "Please consolidate them into the memory above, then write back with append=false.\n" + output += "After integration, the fragment files will be cleaned up automatically.\n\n" + for f in r.fragments { + output += "### \(f.filename)\n" + output += f.content + "\n\n" + } + } + let utf8Bytes = Array(output.utf8) + if utf8Bytes.count <= maxBytes { return output } + let truncated = String(decoding: utf8Bytes.prefix(maxBytes), as: UTF8.self) + return truncated + "\n\n(truncated — read full via orrery_user_memory_read)" + } + + private func writeFragment(content: String, action: String) throws { + let fm = FileManager.default + try fm.createDirectory(at: fragmentsDir, withIntermediateDirectories: true) + let timestamp = ISO8601DateFormatter().string(from: Date()) + let peer = ProcessInfo.processInfo.hostName + .replacingOccurrences(of: ".local", with: "") + let id = String(UUID().uuidString.prefix(8).lowercased()) + let filename = "f-\(id)-\(peer).md" + + let body = """ + --- + id: f-\(id) + peer: \(peer) + timestamp: \(timestamp) + action: \(action) + --- + + \(content) + """ + try body.write( + to: fragmentsDir.appendingPathComponent(filename), + atomically: true, + encoding: .utf8 + ) + } +} diff --git a/Sources/orrery/OrreryCommand.swift b/Sources/orrery/OrreryCommand.swift index cfdb46f..cd525de 100644 --- a/Sources/orrery/OrreryCommand.swift +++ b/Sources/orrery/OrreryCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import OrreryCore public enum OrreryVersion { - public static let current = "2.7.0" + public static let current = "3.0.0" } /// Root CLI command. Lives in the executable target. @@ -42,6 +42,7 @@ public struct OrreryCommand: AsyncParsableCommand { SetCurrentCommand.self, CheckUpdateCommand.self, LinkMemoryCommand.self, + ReconcileUserMemoryHooksCommand.self, SyncCommand.self, OriginCommand.self, UninstallCommand.self, diff --git a/Tests/OrreryTests/CreateCommandTests.swift b/Tests/OrreryTests/CreateCommandTests.swift index 09b4546..53cd898 100644 --- a/Tests/OrreryTests/CreateCommandTests.swift +++ b/Tests/OrreryTests/CreateCommandTests.swift @@ -29,4 +29,39 @@ struct CreateCommandTests { let claudeDir = store.toolConfigDir(tool: .claude, environment: "work") #expect(FileManager.default.fileExists(atPath: claudeDir.path)) } + + @Test("createEnvironment with shareUserMemory=false persists the flag") + func createPersistsShareUserMemoryFalse() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-create-shareuser-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try CreateCommand.createEnvironment( + name: "demo", + description: "", + tool: .claude, + isolateSessions: false, + isolateMemory: false, + shareUserMemory: false, + store: store + ) + let env = try store.load(named: "demo") + #expect(env.shareUserMemory == false) + } + + @Test("createEnvironment defaults shareUserMemory to true") + func createDefaultsShareUserMemory() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-create-default-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try CreateCommand.createEnvironment( + name: "demo", + description: "", + tool: .claude, + store: store + ) + let env = try store.load(named: "demo") + #expect(env.shareUserMemory == true) + } } diff --git a/Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift b/Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift new file mode 100644 index 0000000..44d33ec --- /dev/null +++ b/Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift @@ -0,0 +1,82 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("EnvironmentStore user memory paths") +struct EnvironmentStoreUserMemoryTests { + + @Test("userMemoryDir is ~/.orrery/user/memory under the store home") + func userMemoryDirPath() { + let home = URL(fileURLWithPath: "/tmp/fake-orrery-home") + let store = EnvironmentStore(homeURL: home) + #expect(store.userMemoryDir().path == "/tmp/fake-orrery-home/user/memory") + } + + @Test("ensureUserMemoryHooks installs hooks for each installed tool") + func ensureInstallsForEachTool() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-ensurehooks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + + var env = OrreryEnvironment(name: "e1", tools: [.claude, .codex]) + try store.save(env) + // Pre-create tool config dirs so the installers have a place to write. + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e1") + let codexDir = store.toolConfigDir(tool: .codex, environment: "e1") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e1") + + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) + #expect(CodexHookInstaller().isInstalled(at: codexDir)) + } + + @Test("ensureUserMemoryHooks skips installation when shareUserMemory is false") + func ensureSkipsWhenDisabled() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-ensurehooks-off-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e2", tools: [.claude], shareUserMemory: false) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e2") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e2") + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) + } + + @Test("removeUserMemoryHooks removes from all tools") + func removeFromAllTools() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-removehooks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e3", tools: [.claude, .codex]) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e3") + let codexDir = store.toolConfigDir(tool: .codex, environment: "e3") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e3") + try store.removeUserMemoryHooks(for: "e3") + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) + #expect(!CodexHookInstaller().isInstalled(at: codexDir)) + } + + @Test("addTool installs user-memory hook on the new tool when shareUserMemory=true") + func addToolInstallsHook() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-addtoolhook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [], shareUserMemory: true) + try store.save(env) + try store.addTool(.claude, to: "e") + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) + } +} diff --git a/Tests/OrreryTests/MemoryCommandStructureTests.swift b/Tests/OrreryTests/MemoryCommandStructureTests.swift new file mode 100644 index 0000000..8377995 --- /dev/null +++ b/Tests/OrreryTests/MemoryCommandStructureTests.swift @@ -0,0 +1,37 @@ +import Testing +import ArgumentParser +@testable import OrreryCore + +@Suite("MemoryCommand structure") +struct MemoryCommandStructureTests { + + @Test("MemoryCommand has project and user subcommand groups") + func subgroupsPresent() { + let names = MemoryCommand.configuration.subcommands.map { $0._commandName } + #expect(names.contains("project")) + #expect(names.contains("user")) + } + + @Test("ProjectMemoryCommand exposes info/export/isolate/share/storage") + func projectSubcommandsExist() { + let names = MemoryCommand.ProjectSubcommand.configuration.subcommands.map { $0._commandName } + for expected in ["info", "export", "isolate", "share", "storage"] { + #expect(names.contains(expected), "missing subcommand: \(expected)") + } + } + + @Test("orrery memory no longer has top-level info subcommand") + func topLevelFlatRemoved() { + let names = MemoryCommand.configuration.subcommands.map { $0._commandName } + #expect(!names.contains("info")) + #expect(!names.contains("isolate")) + #expect(!names.contains("share")) + #expect(!names.contains("storage")) + #expect(!names.contains("export")) + } +} + +// Helper: surfaces the configured command name for assertions. +extension ParsableCommand { + static var _commandName: String { configuration.commandName ?? "\(self)".lowercased() } +} diff --git a/Tests/OrreryTests/MemoryStoreTests.swift b/Tests/OrreryTests/MemoryStoreTests.swift new file mode 100644 index 0000000..362fb27 --- /dev/null +++ b/Tests/OrreryTests/MemoryStoreTests.swift @@ -0,0 +1,117 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("MemoryStore") +struct MemoryStoreTests { + let tmpDir: URL + + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-mstore-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("read() on empty dir returns empty string") + func readEmpty() throws { + let store = MemoryStore(directory: tmpDir) + #expect(try store.read().memory.isEmpty) + #expect(try store.read().fragments.isEmpty) + } + + @Test("write(append:false) creates MEMORY.md and a fragment") + func writeOverwrite() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "hello", append: false) + + let memory = try String(contentsOf: tmpDir.appendingPathComponent("MEMORY.md"), encoding: .utf8) + #expect(memory == "hello") + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + #expect(fragments.count == 1) + let body = try String(contentsOf: tmpDir.appendingPathComponent("fragments").appendingPathComponent(fragments[0]), encoding: .utf8) + #expect(body.contains("action: overwrite")) + #expect(body.contains("hello")) + } + + @Test("write(append:true) appends with leading newline + fragment") + func writeAppend() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "first", append: false) + try store.write(content: "second", append: true) + + let memory = try String(contentsOf: tmpDir.appendingPathComponent("MEMORY.md"), encoding: .utf8) + #expect(memory == "first\nsecond") + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + #expect(fragments.count == 2) + } + + @Test("write(append:false) cleans up existing fragments after writing the new fragment") + func writeOverwriteCleansFragments() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "a", append: true) + try store.write(content: "b", append: true) + try store.write(content: "consolidated", append: false) + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + // Only the "overwrite" fragment from the consolidation call remains. + #expect(fragments.count == 1) + let body = try String(contentsOf: tmpDir.appendingPathComponent("fragments").appendingPathComponent(fragments[0]), encoding: .utf8) + #expect(body.contains("action: overwrite")) + } + + @Test("read() returns pending fragments sorted by filename") + func readReturnsFragments() throws { + let store = MemoryStore(directory: tmpDir) + let fragDir = tmpDir.appendingPathComponent("fragments") + try FileManager.default.createDirectory(at: fragDir, withIntermediateDirectories: true) + try "frag-a-body".write(to: fragDir.appendingPathComponent("f-aaa-host.md"), atomically: true, encoding: .utf8) + try "frag-b-body".write(to: fragDir.appendingPathComponent("f-bbb-host.md"), atomically: true, encoding: .utf8) + + let result = try store.read() + #expect(result.fragments.map(\.filename) == ["f-aaa-host.md", "f-bbb-host.md"]) + #expect(result.fragments[0].content == "frag-a-body") + } + + @Test("emit returns empty string when MEMORY.md is missing") + func emitMissing() throws { + let store = MemoryStore(directory: tmpDir) + #expect(try store.emit(maxBytes: 25_600) == "") + } + + @Test("emit returns MEMORY.md content when small") + func emitSmall() throws { + let store = MemoryStore(directory: tmpDir) + // Seed MEMORY.md directly — `write()` would also produce a fragment as a side + // effect, which would alter emit's output. Same seeding technique as + // `emitWithFragments` / `readReturnsFragments`. + try "tiny memory".write(to: tmpDir.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8) + let out = try store.emit(maxBytes: 25_600) + #expect(out == "tiny memory") + } + + @Test("emit appends pending fragments block") + func emitWithFragments() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "main", append: false) + let fragDir = tmpDir.appendingPathComponent("fragments") + try "fragbody".write(to: fragDir.appendingPathComponent("f-x-host.md"), atomically: true, encoding: .utf8) + let out = try store.emit(maxBytes: 25_600) + #expect(out.contains("main")) + #expect(out.contains("Pending Memory Fragments")) + #expect(out.contains("f-x-host.md")) + #expect(out.contains("fragbody")) + } + + @Test("emit truncates at maxBytes and appends truncation hint") + func emitTruncates() throws { + let store = MemoryStore(directory: tmpDir) + let big = String(repeating: "x", count: 30_000) + try store.write(content: big, append: false) + let out = try store.emit(maxBytes: 100) + #expect(out.count > 100) // truncation hint adds bytes + #expect(out.contains("truncated")) + #expect(out.hasPrefix(String(repeating: "x", count: 100))) + } +} diff --git a/Tests/OrreryTests/ModelTests.swift b/Tests/OrreryTests/ModelTests.swift index 031b81e..45409d7 100644 --- a/Tests/OrreryTests/ModelTests.swift +++ b/Tests/OrreryTests/ModelTests.swift @@ -21,6 +21,33 @@ struct OrreryEnvironmentTests { #expect(decoded.tools == [.claude, .codex]) #expect(decoded.env["ANTHROPIC_API_KEY"] == "sk-test") } + + @Test("OrreryEnvironment.shareUserMemory defaults to true") + func envShareUserMemoryDefault() { + let e = OrreryEnvironment(name: "x") + #expect(e.shareUserMemory == true) + } + + @Test("OrreryEnvironment legacy JSON decodes shareUserMemory=true") + func envLegacyDecodeShareUserMemory() throws { + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "x", + "description": "", + "createdAt": "2026-01-01T00:00:00Z", + "lastUsed": "2026-01-01T00:00:00Z", + "tools": [], + "env": {}, + "isolatedSessionTools": [], + "isolateMemory": false + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let e = try decoder.decode(OrreryEnvironment.self, from: json) + #expect(e.shareUserMemory == true) + } } @Suite("Tool") @@ -46,3 +73,22 @@ struct ToolTests { #expect(Tool(rawValue: "unknown") == nil) } } + +@Suite("OriginConfig") +struct OriginConfigTests { + + @Test("OriginConfig.shareUserMemory defaults to true") + func originConfigShareUserMemoryDefault() { + let c = OriginConfig() + #expect(c.shareUserMemory == true) + } + + @Test("OriginConfig decodes legacy JSON without shareUserMemory as enabled") + func originConfigLegacyDecodeShareUserMemory() throws { + let json = """ + { "isolateMemory": false, "isolatedSessionTools": [] } + """.data(using: .utf8)! + let c = try JSONDecoder().decode(OriginConfig.self, from: json) + #expect(c.shareUserMemory == true) + } +} diff --git a/Tests/OrreryTests/SetupCommandTests.swift b/Tests/OrreryTests/SetupCommandTests.swift index b320679..6eddca7 100644 --- a/Tests/OrreryTests/SetupCommandTests.swift +++ b/Tests/OrreryTests/SetupCommandTests.swift @@ -79,4 +79,19 @@ struct SetupCommandTests { #expect(content.contains("# orrery shell integration (lazy bootstrap)")) #expect(content.contains("export FOO=bar")) } + + @Test("origin setup with shareUserMemory=false skips hook installation") + func originSkipsWhenDisabled() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-origin-su-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try store.saveOriginConfig(OriginConfig(shareUserMemory: false)) + // Pre-create a Claude config dir as if takeover had happened + let claudeDir = store.originConfigDir(tool: .claude) + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + // ensureUserMemoryHooks reads OriginConfig.shareUserMemory and short-circuits + try store.ensureUserMemoryHooks(for: ReservedEnvironment.defaultName) + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) + } } diff --git a/Tests/OrreryTests/ShellFunctionGeneratorTests.swift b/Tests/OrreryTests/ShellFunctionGeneratorTests.swift index 8cb55c2..37b2811 100644 --- a/Tests/OrreryTests/ShellFunctionGeneratorTests.swift +++ b/Tests/OrreryTests/ShellFunctionGeneratorTests.swift @@ -30,4 +30,10 @@ struct ShellFunctionGeneratorTests { #expect(script.contains("_orrery_init")) #expect(script.contains("current")) } + + @Test("generated shell function calls _reconcile-user-memory-hooks") + func shellCallsReconcile() { + let out = ShellFunctionGenerator.generate(version: "9.9.9") + #expect(out.contains("_reconcile-user-memory-hooks")) + } } diff --git a/Tests/OrreryTests/UserMemoryCommandTests.swift b/Tests/OrreryTests/UserMemoryCommandTests.swift new file mode 100644 index 0000000..563597f --- /dev/null +++ b/Tests/OrreryTests/UserMemoryCommandTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("UserMemoryCommand") +struct UserMemoryCommandTests { + + @Test("emit prints empty string when no memory file exists") + func emitEmpty() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-uemit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + let output = try UserMemoryCommand.emit(store: store) + #expect(output == "") + } + + @Test("emit prints MEMORY.md content when present") + func emitWithFile() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-uemit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + let dir = store.userMemoryDir() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try "global memory".write(to: dir.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8) + let output = try UserMemoryCommand.emit(store: store) + #expect(output == "global memory") + } + + @Test("enable sets shareUserMemory=true and installs hooks for current env") + func enableInstallsHooks() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-enable-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [.claude], shareUserMemory: false) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try UserMemoryCommand.applyEnable(envName: "e", store: store) + + let updated = try store.load(named: "e") + #expect(updated.shareUserMemory == true) + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) + } + + @Test("disable sets shareUserMemory=false and removes hooks") + func disableRemovesHooks() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-disable-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [.claude], shareUserMemory: true) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try store.ensureUserMemoryHooks(for: "e") + + try UserMemoryCommand.applyDisable(envName: "e", store: store) + + let updated = try store.load(named: "e") + #expect(updated.shareUserMemory == false) + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) + } +} diff --git a/Tests/OrreryTests/UserMemoryHookInstallerTests.swift b/Tests/OrreryTests/UserMemoryHookInstallerTests.swift new file mode 100644 index 0000000..a78cd53 --- /dev/null +++ b/Tests/OrreryTests/UserMemoryHookInstallerTests.swift @@ -0,0 +1,160 @@ +import Testing +import Foundation +@testable import OrreryCore + +@Suite("ClaudeHookInstaller") +struct ClaudeHookInstallerTests { + let tmpDir: URL + + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-claudehook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("install on empty config creates settings.json with our hook entry") + func installEmpty() throws { + let installer = ClaudeHookInstaller() + try installer.install(at: tmpDir) + let settings = tmpDir.appendingPathComponent("settings.json") + let body = try String(contentsOf: settings, encoding: .utf8) + #expect(body.contains("\"command\"")) + #expect(body.contains("orrery memory user emit")) + #expect(body.contains("\"_orrery_managed\"")) + } + + @Test("install is idempotent") + func installIdempotent() throws { + let installer = ClaudeHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + let settings = tmpDir.appendingPathComponent("settings.json") + let data = try Data(contentsOf: settings) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + let sessionStart = hooks["SessionStart"] as! [[String: Any]] + let firstMatcher = sessionStart[0] + let entries = firstMatcher["hooks"] as! [[String: Any]] + let managed = entries.filter { ($0["_orrery_managed"] as? Bool) == true } + #expect(managed.count == 1) + } + + @Test("install preserves foreign hook entries") + func installPreservesForeign() throws { + let settings = tmpDir.appendingPathComponent("settings.json") + let foreign: [String: Any] = [ + "hooks": [ + "SessionStart": [ + [ + "matcher": "*", + "hooks": [ + ["type": "command", "command": "echo something-else"] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: foreign, options: [.prettyPrinted]) + try data.write(to: settings) + + try ClaudeHookInstaller().install(at: tmpDir) + + let updated = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + let hooks = updated["hooks"] as! [String: Any] + let sessionStart = hooks["SessionStart"] as! [[String: Any]] + let entries = sessionStart[0]["hooks"] as! [[String: Any]] + #expect(entries.count == 2) + let commands = entries.compactMap { $0["command"] as? String } + #expect(commands.contains("echo something-else")) + #expect(commands.contains("orrery memory user emit")) + } + + @Test("remove only deletes _orrery_managed entries") + func removeKeepsForeign() throws { + let settings = tmpDir.appendingPathComponent("settings.json") + try ClaudeHookInstaller().install(at: tmpDir) + // Inject a foreign entry next to ours + var json = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + var hooks = json["hooks"] as! [String: Any] + var sessionStart = hooks["SessionStart"] as! [[String: Any]] + var entries = sessionStart[0]["hooks"] as! [[String: Any]] + entries.append(["type": "command", "command": "echo foreign"]) + sessionStart[0]["hooks"] = entries + hooks["SessionStart"] = sessionStart + json["hooks"] = hooks + try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) + .write(to: settings) + + try ClaudeHookInstaller().remove(at: tmpDir) + + let final = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + let finalHooks = final["hooks"] as! [String: Any] + let finalSessionStart = finalHooks["SessionStart"] as! [[String: Any]] + let finalEntries = finalSessionStart[0]["hooks"] as! [[String: Any]] + #expect(finalEntries.count == 1) + #expect((finalEntries[0]["command"] as? String) == "echo foreign") + } + + @Test("isInstalled true after install, false after remove") + func isInstalledStatus() throws { + let installer = ClaudeHookInstaller() + #expect(!installer.isInstalled(at: tmpDir)) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} + +@Suite("CodexHookInstaller") +struct CodexHookInstallerTests { + let tmpDir: URL + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-codexhook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("Codex installer targets hooks.json, not config.toml") + func codexTargetsHooksJSON() throws { + try CodexHookInstaller().install(at: tmpDir) + #expect(FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("hooks.json").path)) + #expect(!FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("config.toml").path)) + } + + @Test("Codex installer is idempotent and removable") + func codexLifecycle() throws { + let installer = CodexHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} + +@Suite("GeminiHookInstaller") +struct GeminiHookInstallerTests { + let tmpDir: URL + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-geminihook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("Gemini installer targets settings.json in configDir") + func geminiTargetsSettings() throws { + try GeminiHookInstaller().install(at: tmpDir) + #expect(FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("settings.json").path)) + } + + @Test("Gemini installer is idempotent and removable") + func geminiLifecycle() throws { + let installer = GeminiHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} diff --git a/docs/index.html b/docs/index.html index 1c4d88f..229259a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -578,7 +578,7 @@
-
v2.7.0
+
v3.0.0

Manage AI CLI environments, per shell

Isolate accounts for Claude Code, Codex, and Gemini CLI across work and personal contexts — switch accounts freely while keeping your conversations continuous.

diff --git a/docs/superpowers/plans/2026-05-19-user-level-memory-layer.md b/docs/superpowers/plans/2026-05-19-user-level-memory-layer.md new file mode 100644 index 0000000..62a19d6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-user-level-memory-layer.md @@ -0,0 +1,2561 @@ +# User-level Memory Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a true user-global memory layer to Orrery — a directory at `~/.orrery/user/memory/` accessed by AI tools via MCP (writes) and a SessionStart hook (auto-load), with hook installers for Claude, Codex, and Gemini. Also reorganises the `orrery memory` CLI into `project` / `user` sub-groups (breaking change, v3.0.0). + +**Architecture:** New `MemoryStore` value type encapsulates read/write/fragment logic so the same code serves both project- and user-layer storage. A `UserMemoryHookInstaller` protocol with three per-tool implementations writes idempotent `_orrery_managed: true` hook entries into each tool's settings JSON. `orrery use` reconciles hook state on every switch via a new internal `_reconcile-user-memory-hooks` command. + +**Tech Stack:** Swift 6 (swift-tools-version: 6.0), swift-argument-parser, Swift Testing (`@Suite`/`@Test`/`#expect`), Foundation JSON encoder/decoder. + +**Spec reference:** `docs/superpowers/specs/2026-05-18-user-level-memory-layer-design.md` + +--- + +## File Map + +**Create:** +- `Sources/OrreryCore/Storage/MemoryStore.swift` — shared read/write/fragment helper +- `Sources/OrreryCore/Commands/UserMemoryCommand.swift` — `orrery memory user ...` subcommands +- `Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift` — internal `_reconcile-user-memory-hooks` +- `Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift` — protocol + 3 implementations +- `Tests/OrreryTests/MemoryStoreTests.swift` +- `Tests/OrreryTests/UserMemoryCommandTests.swift` +- `Tests/OrreryTests/UserMemoryHookInstallerTests.swift` +- `Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift` + +**Modify:** +- `Sources/OrreryCore/Models/OrreryEnvironment.swift` — add `shareUserMemory: Bool` to both `OriginConfig` and `OrreryEnvironment` +- `Sources/OrreryCore/Storage/EnvironmentStore.swift` — `userMemoryDir`, `ensureUserMemoryHooks`, `removeUserMemoryHooks` +- `Sources/OrreryCore/MCP/MCPServer.swift` — register `orrery_user_memory_read/write`, refactor existing memory code onto `MemoryStore` +- `Sources/OrreryCore/Commands/MemoryCommand.swift` — restructure into `project` / `user` sub-groups (BREAKING rename) +- `Sources/OrreryCore/Commands/OrreryCommand.swift` — bump `OrreryVersion.current` to `"3.0.0"`, register new `ReconcileUserMemoryHooksCommand` +- `Sources/OrreryCore/Commands/CreateCommand.swift` — wizard question + `shareUserMemory` param on `createEnvironment` +- `Sources/OrreryCore/Setup/ToolSetupRunner.swift` — call `ensureUserMemoryHooks` after `addTool` +- `Sources/OrreryCore/Shell/ShellFunctionGenerator.swift` — add `orrery-bin _reconcile-user-memory-hooks` call inside the `use` shell function, after env-var export +- `Sources/OrreryCore/Resources/Localization/en.json` — new keys +- `Sources/OrreryCore/Resources/Localization/ja.json` — new keys +- `Sources/OrreryCore/Resources/Localization/zh-Hant.json` — new keys +- `Sources/OrreryCore/Resources/Localization/l10n-signatures.json` — regenerated +- `CHANGELOG.md` — v3.0.0 entry +- `docs/index.html` and `docs/zh_TW.html` — version badge + +**Not in this plan (Future Work in spec):** `orrery memory user import`, custom user-memory storage path. + +--- + +## Phase A — Foundation + +### Task 1: Add `shareUserMemory` to `OriginConfig` + +**Files:** +- Modify: `Sources/OrreryCore/Models/OrreryEnvironment.swift:9-29` +- Test: `Tests/OrreryTests/ModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +Append in `Tests/OrreryTests/ModelTests.swift` (find the test suite that already covers OriginConfig; if none, add a new `@Suite("OriginConfig")` block): + +```swift +@Test("OriginConfig.shareUserMemory defaults to true") +func originConfigShareUserMemoryDefault() { + let c = OriginConfig() + #expect(c.shareUserMemory == true) +} + +@Test("OriginConfig decodes legacy JSON without shareUserMemory as enabled") +func originConfigLegacyDecodeShareUserMemory() throws { + let json = """ + { "isolateMemory": false, "isolatedSessionTools": [] } + """.data(using: .utf8)! + let c = try JSONDecoder().decode(OriginConfig.self, from: json) + #expect(c.shareUserMemory == true) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter "OriginConfig" +``` + +Expected: FAIL — `OriginConfig` has no `shareUserMemory` member. + +- [ ] **Step 3: Add the field** + +In `Sources/OrreryCore/Models/OrreryEnvironment.swift` change the `OriginConfig` struct to: + +```swift +public struct OriginConfig: Codable, Sendable { + public var isolateMemory: Bool + public var memoryStoragePath: String? + public var isolatedSessionTools: Set + public var shareUserMemory: Bool + + public init( + isolateMemory: Bool = true, + memoryStoragePath: String? = nil, + isolatedSessionTools: Set = [], + shareUserMemory: Bool = true + ) { + self.isolateMemory = isolateMemory + self.memoryStoragePath = memoryStoragePath + self.isolatedSessionTools = isolatedSessionTools + self.shareUserMemory = shareUserMemory + } + + private enum CodingKeys: String, CodingKey { + case isolateMemory, memoryStoragePath, isolatedSessionTools, shareUserMemory + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + isolateMemory = try c.decodeIfPresent(Bool.self, forKey: .isolateMemory) ?? true + memoryStoragePath = try c.decodeIfPresent(String.self, forKey: .memoryStoragePath) + isolatedSessionTools = try c.decodeIfPresent(Set.self, forKey: .isolatedSessionTools) ?? [] + shareUserMemory = try c.decodeIfPresent(Bool.self, forKey: .shareUserMemory) ?? true + } + + public func isolateSessions(for tool: Tool) -> Bool { + isolatedSessionTools.contains(tool) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter "OriginConfig" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Models/OrreryEnvironment.swift Tests/OrreryTests/ModelTests.swift +git commit -m "[FEAT] OriginConfig.shareUserMemory field (default true)" +``` + +--- + +### Task 2: Add `shareUserMemory` to `OrreryEnvironment` + +**Files:** +- Modify: `Sources/OrreryCore/Models/OrreryEnvironment.swift:31-end` +- Test: `Tests/OrreryTests/ModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +@Test("OrreryEnvironment.shareUserMemory defaults to true") +func envShareUserMemoryDefault() { + let e = OrreryEnvironment(name: "x") + #expect(e.shareUserMemory == true) +} + +@Test("OrreryEnvironment legacy JSON decodes shareUserMemory=true") +func envLegacyDecodeShareUserMemory() throws { + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "x", + "description": "", + "createdAt": "2026-01-01T00:00:00Z", + "lastUsed": "2026-01-01T00:00:00Z", + "tools": [], + "env": {}, + "isolatedSessionTools": [], + "isolateMemory": false + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let e = try decoder.decode(OrreryEnvironment.self, from: json) + #expect(e.shareUserMemory == true) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter "OrreryEnvironment" +``` + +Expected: FAIL — no `shareUserMemory` member. + +- [ ] **Step 3: Add the field** + +In `Sources/OrreryCore/Models/OrreryEnvironment.swift`, find the `OrreryEnvironment` struct (around line 31). Add a stored property `public var shareUserMemory: Bool` next to `isolateMemory`. Update the `init(...)` parameter list to accept `shareUserMemory: Bool = true` and assign. Add `case shareUserMemory` to `CodingKeys`, decode with `decodeIfPresent(...) ?? true`, encode unconditionally. + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter "OrreryEnvironment" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Models/OrreryEnvironment.swift Tests/OrreryTests/ModelTests.swift +git commit -m "[FEAT] OrreryEnvironment.shareUserMemory field (default true)" +``` + +--- + +### Task 3: `EnvironmentStore.userMemoryDir` + +**Files:** +- Modify: `Sources/OrreryCore/Storage/EnvironmentStore.swift` (insert near the existing memory-path helpers, around line 251) +- Test: `Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift` (new) + +- [ ] **Step 1: Write the failing test** + +Create `Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift`: + +```swift +import Testing +import Foundation +@testable import OrreryCore + +@Suite("EnvironmentStore user memory paths") +struct EnvironmentStoreUserMemoryTests { + + @Test("userMemoryDir is ~/.orrery/user/memory under the store home") + func userMemoryDirPath() { + let home = URL(fileURLWithPath: "/tmp/fake-orrery-home") + let store = EnvironmentStore(homeURL: home) + #expect(store.userMemoryDir().path == "/tmp/fake-orrery-home/user/memory") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter EnvironmentStoreUserMemoryTests +``` + +Expected: FAIL — `userMemoryDir` undefined. + +- [ ] **Step 3: Add the method** + +In `Sources/OrreryCore/Storage/EnvironmentStore.swift`, in the `// MARK: - Memory path helpers` section, append: + +```swift +/// User-global memory dir: `~/.orrery/user/memory/`. +/// Independent of any env or projectKey — same path for every project, every env. +public func userMemoryDir() -> URL { + homeURL + .appendingPathComponent("user") + .appendingPathComponent("memory") +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter EnvironmentStoreUserMemoryTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Storage/EnvironmentStore.swift Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift +git commit -m "[FEAT] EnvironmentStore.userMemoryDir() returns ~/.orrery/user/memory" +``` + +--- + +### Task 4: Extract `MemoryStore` helper + +**Files:** +- Create: `Sources/OrreryCore/Storage/MemoryStore.swift` +- Create: `Tests/OrreryTests/MemoryStoreTests.swift` + +This task introduces the shared value type without rewiring `MCPServer` yet. That refactor happens in Task 9. + +- [ ] **Step 1: Write the failing test** + +Create `Tests/OrreryTests/MemoryStoreTests.swift`: + +```swift +import Testing +import Foundation +@testable import OrreryCore + +@Suite("MemoryStore") +struct MemoryStoreTests { + let tmpDir: URL + + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-mstore-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("read() on empty dir returns empty string") + func readEmpty() throws { + let store = MemoryStore(directory: tmpDir) + #expect(try store.read().memory.isEmpty) + #expect(try store.read().fragments.isEmpty) + } + + @Test("write(append:false) creates MEMORY.md and a fragment") + func writeOverwrite() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "hello", append: false) + + let memory = try String(contentsOf: tmpDir.appendingPathComponent("MEMORY.md"), encoding: .utf8) + #expect(memory == "hello") + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + #expect(fragments.count == 1) + let body = try String(contentsOf: tmpDir.appendingPathComponent("fragments").appendingPathComponent(fragments[0]), encoding: .utf8) + #expect(body.contains("action: overwrite")) + #expect(body.contains("hello")) + } + + @Test("write(append:true) appends with leading newline + fragment") + func writeAppend() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "first", append: false) + try store.write(content: "second", append: true) + + let memory = try String(contentsOf: tmpDir.appendingPathComponent("MEMORY.md"), encoding: .utf8) + #expect(memory == "first\nsecond") + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + #expect(fragments.count == 2) + } + + @Test("write(append:false) cleans up existing fragments after writing the new fragment") + func writeOverwriteCleansFragments() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "a", append: true) + try store.write(content: "b", append: true) + try store.write(content: "consolidated", append: false) + + let fragments = try FileManager.default.contentsOfDirectory(atPath: tmpDir.appendingPathComponent("fragments").path) + // Only the "overwrite" fragment from the consolidation call remains. + #expect(fragments.count == 1) + let body = try String(contentsOf: tmpDir.appendingPathComponent("fragments").appendingPathComponent(fragments[0]), encoding: .utf8) + #expect(body.contains("action: overwrite")) + } + + @Test("read() returns pending fragments sorted by filename") + func readReturnsFragments() throws { + let store = MemoryStore(directory: tmpDir) + let fragDir = tmpDir.appendingPathComponent("fragments") + try FileManager.default.createDirectory(at: fragDir, withIntermediateDirectories: true) + try "frag-a-body".write(to: fragDir.appendingPathComponent("f-aaa-host.md"), atomically: true, encoding: .utf8) + try "frag-b-body".write(to: fragDir.appendingPathComponent("f-bbb-host.md"), atomically: true, encoding: .utf8) + + let result = try store.read() + #expect(result.fragments.map(\.filename) == ["f-aaa-host.md", "f-bbb-host.md"]) + #expect(result.fragments[0].content == "frag-a-body") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter MemoryStoreTests +``` + +Expected: FAIL — `MemoryStore` undefined. + +- [ ] **Step 3: Implement `MemoryStore`** + +Create `Sources/OrreryCore/Storage/MemoryStore.swift`: + +```swift +import Foundation + +/// Reads / writes a markdown memory store at `directory/`. +/// +/// Layout: +/// - `directory/MEMORY.md` — canonical index, what the AI agent reads/writes. +/// - `directory/fragments/f-{id}-{peer}.md` — per-write fragment for cross-machine sync. +/// +/// Used by both the project-level (per `projectKey` / env) and user-level +/// (`~/.orrery/user/memory/`) memory layers. The two layers differ only in +/// which `directory` they point at. +public struct MemoryStore: Sendable { + public let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + public struct Fragment: Sendable, Equatable { + public let filename: String + public let content: String + } + + public struct ReadResult: Sendable { + public let memory: String + public let fragments: [Fragment] + } + + private var memoryFile: URL { directory.appendingPathComponent("MEMORY.md") } + private var fragmentsDir: URL { directory.appendingPathComponent("fragments") } + + /// Read `MEMORY.md` plus any pending fragments. Both default to empty when missing. + public func read() throws -> ReadResult { + let fm = FileManager.default + var memory = "" + if fm.fileExists(atPath: memoryFile.path) { + memory = try String(contentsOf: memoryFile, encoding: .utf8) + } + + var fragments: [Fragment] = [] + if fm.fileExists(atPath: fragmentsDir.path) { + let names = (try? fm.contentsOfDirectory(atPath: fragmentsDir.path)) ?? [] + for name in names.sorted() where name.hasSuffix(".md") { + let url = fragmentsDir.appendingPathComponent(name) + if let body = try? String(contentsOf: url, encoding: .utf8) { + fragments.append(Fragment(filename: name, content: body)) + } + } + } + return ReadResult(memory: memory, fragments: fragments) + } + + /// Write or append to `MEMORY.md`, and record a fragment of the same write. + /// When `append == false`, cleans up *prior* fragments before recording the new one — this + /// is the consolidation contract: an overwrite means "the agent has integrated everything". + public func write(content: String, append: Bool) throws { + let fm = FileManager.default + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + + if append, fm.fileExists(atPath: memoryFile.path) { + let existing = try String(contentsOf: memoryFile, encoding: .utf8) + try (existing + "\n" + content).write(to: memoryFile, atomically: true, encoding: .utf8) + } else { + try content.write(to: memoryFile, atomically: true, encoding: .utf8) + cleanupFragments() + } + + try writeFragment(content: content, action: append ? "append" : "overwrite") + } + + /// Remove all fragments from `fragments/`. Best-effort; missing dir is fine. + public func cleanupFragments() { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: fragmentsDir.path) else { return } + for name in names where name.hasSuffix(".md") { + try? fm.removeItem(at: fragmentsDir.appendingPathComponent(name)) + } + } + + private func writeFragment(content: String, action: String) throws { + let fm = FileManager.default + try fm.createDirectory(at: fragmentsDir, withIntermediateDirectories: true) + let timestamp = ISO8601DateFormatter().string(from: Date()) + let peer = ProcessInfo.processInfo.hostName + .replacingOccurrences(of: ".local", with: "") + let id = String(UUID().uuidString.prefix(8).lowercased()) + let filename = "f-\(id)-\(peer).md" + + let body = """ + --- + id: f-\(id) + peer: \(peer) + timestamp: \(timestamp) + action: \(action) + --- + + \(content) + """ + try body.write( + to: fragmentsDir.appendingPathComponent(filename), + atomically: true, + encoding: .utf8 + ) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter MemoryStoreTests +``` + +Expected: PASS (all 5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Storage/MemoryStore.swift Tests/OrreryTests/MemoryStoreTests.swift +git commit -m "[FEAT] introduce MemoryStore value type for shared read/write/fragment logic" +``` + +--- + +## Phase B — MCP & CLI plumbing + +### Task 5: `MemoryStore.emit()` helper for hook output + +**Files:** +- Modify: `Sources/OrreryCore/Storage/MemoryStore.swift` +- Modify: `Tests/OrreryTests/MemoryStoreTests.swift` + +- [ ] **Step 1: Write the failing test** + +Append to `MemoryStoreTests.swift`: + +```swift +@Test("emit returns empty string when MEMORY.md is missing") +func emitMissing() throws { + let store = MemoryStore(directory: tmpDir) + #expect(try store.emit(maxBytes: 25_600) == "") +} + +@Test("emit returns MEMORY.md content when small") +func emitSmall() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "tiny memory", append: false) + let out = try store.emit(maxBytes: 25_600) + #expect(out == "tiny memory") +} + +@Test("emit appends pending fragments block") +func emitWithFragments() throws { + let store = MemoryStore(directory: tmpDir) + try store.write(content: "main", append: false) + let fragDir = tmpDir.appendingPathComponent("fragments") + try "fragbody".write(to: fragDir.appendingPathComponent("f-x-host.md"), atomically: true, encoding: .utf8) + let out = try store.emit(maxBytes: 25_600) + #expect(out.contains("main")) + #expect(out.contains("Pending Memory Fragments")) + #expect(out.contains("f-x-host.md")) + #expect(out.contains("fragbody")) +} + +@Test("emit truncates at maxBytes and appends truncation hint") +func emitTruncates() throws { + let store = MemoryStore(directory: tmpDir) + let big = String(repeating: "x", count: 30_000) + try store.write(content: big, append: false) + let out = try store.emit(maxBytes: 100) + #expect(out.count > 100) // truncation hint adds bytes + #expect(out.contains("truncated")) + #expect(out.hasPrefix(String(repeating: "x", count: 100))) +} +``` + +The fragments-block write in the test above sidesteps `MemoryStore.write` because that would clean up fragments — fine, we're seeding state directly. + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter MemoryStoreTests +``` + +Expected: FAIL — `emit` undefined. + +- [ ] **Step 3: Implement `emit`** + +Append to `MemoryStore`: + +```swift +/// Produce the hook-stdout / read-tool output: MEMORY.md content optionally followed +/// by a "Pending Memory Fragments" block, truncated to `maxBytes`. +public func emit(maxBytes: Int) throws -> String { + let r = try read() + var output = r.memory + if !r.fragments.isEmpty { + output += "\n\n---\n## Pending Memory Fragments (from sync)\n" + output += "The following fragments were synced from other machines and need to be integrated.\n" + output += "Please consolidate them into the memory above, then write back with append=false.\n" + output += "After integration, the fragment files will be cleaned up automatically.\n\n" + for f in r.fragments { + output += "### \(f.filename)\n" + output += f.content + "\n\n" + } + } + let utf8Bytes = Array(output.utf8) + if utf8Bytes.count <= maxBytes { return output } + let truncated = String(decoding: utf8Bytes.prefix(maxBytes), as: UTF8.self) + return truncated + "\n\n(truncated — read full via orrery_user_memory_read)" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter MemoryStoreTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Storage/MemoryStore.swift Tests/OrreryTests/MemoryStoreTests.swift +git commit -m "[FEAT] MemoryStore.emit produces capped hook output with fragments block" +``` + +--- + +### Task 6: `UserMemoryCommand` skeleton with `path` / `info` / `emit` + +**Files:** +- Create: `Sources/OrreryCore/Commands/UserMemoryCommand.swift` +- Create: `Tests/OrreryTests/UserMemoryCommandTests.swift` + +- [ ] **Step 1: Write the failing test** + +Create `Tests/OrreryTests/UserMemoryCommandTests.swift`: + +```swift +import Testing +import Foundation +@testable import OrreryCore + +@Suite("UserMemoryCommand") +struct UserMemoryCommandTests { + + @Test("emit prints empty string when no memory file exists") + func emitEmpty() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-uemit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + let output = try UserMemoryCommand.emit(store: store) + #expect(output == "") + } + + @Test("emit prints MEMORY.md content when present") + func emitWithFile() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-uemit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + let dir = store.userMemoryDir() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try "global memory".write(to: dir.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8) + let output = try UserMemoryCommand.emit(store: store) + #expect(output == "global memory") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter UserMemoryCommandTests +``` + +Expected: FAIL — `UserMemoryCommand` undefined. + +- [ ] **Step 3: Implement the command + subcommands** + +Create `Sources/OrreryCore/Commands/UserMemoryCommand.swift`: + +```swift +import ArgumentParser +import Foundation + +public struct UserMemoryCommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "user", + abstract: L10n.UserMemory.abstract, + subcommands: [ + InfoSubcommand.self, + PathSubcommand.self, + EmitSubcommand.self, + ExportSubcommand.self, + EnableSubcommand.self, + DisableSubcommand.self, + ] + ) + + public init() {} + + /// Pure helper used by tests and EmitSubcommand. Returns what would be printed + /// to stdout by `orrery memory user emit`. Capped at 25_600 bytes. + public static func emit(store: EnvironmentStore) throws -> String { + let dir = store.userMemoryDir() + let store = MemoryStore(directory: dir) + return try store.emit(maxBytes: 25_600) + } + + public struct InfoSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "info", + abstract: L10n.UserMemory.infoAbstract + ) + public init() {} + public func run() throws { + let store = EnvironmentStore.default + let dir = store.userMemoryDir() + let memoryFile = dir.appendingPathComponent("MEMORY.md") + let fm = FileManager.default + let exists = fm.fileExists(atPath: memoryFile.path) + let size = (try? fm.attributesOfItem(atPath: memoryFile.path)[.size] as? Int) ?? 0 + print(L10n.UserMemory.statusPath(dir.path)) + print(L10n.UserMemory.statusExists(exists, size)) + } + } + + public struct PathSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "path", + abstract: L10n.UserMemory.pathAbstract + ) + public init() {} + public func run() throws { + print(EnvironmentStore.default.userMemoryDir().path) + } + } + + public struct EmitSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "emit", + abstract: L10n.UserMemory.emitAbstract + ) + public init() {} + public func run() throws { + // Best-effort: never fail a hook. + let output = (try? UserMemoryCommand.emit(store: .default)) ?? "" + if !output.isEmpty { + print(output) + } + } + } + + public struct ExportSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "export", + abstract: L10n.UserMemory.exportAbstract + ) + @Option(name: .shortAndLong, help: ArgumentHelp(L10n.UserMemory.exportOutputHelp)) + public var output: String? + public init() {} + public func run() throws { + let store = EnvironmentStore.default + let memoryFile = store.userMemoryDir().appendingPathComponent("MEMORY.md") + guard FileManager.default.fileExists(atPath: memoryFile.path) else { + print(L10n.UserMemory.noMemory) + return + } + let content = try String(contentsOf: memoryFile, encoding: .utf8) + let outputPath = output ?? "USER_MEMORY.md" + let outputURL = URL(fileURLWithPath: outputPath) + try content.write(to: outputURL, atomically: true, encoding: .utf8) + print(L10n.UserMemory.exported(outputURL.path)) + } + } + + // Filled in by Task 14. + public struct EnableSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "enable", + abstract: L10n.UserMemory.enableAbstract + ) + public init() {} + public func run() throws { + throw ValidationError("not yet implemented") + } + } + + public struct DisableSubcommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "disable", + abstract: L10n.UserMemory.disableAbstract + ) + public init() {} + public func run() throws { + throw ValidationError("not yet implemented") + } + } +} +``` + +The `L10n.UserMemory.*` keys do not exist yet — they'll be added in Task 8. **For now**, replace each `L10n.UserMemory.*` reference with a hard-coded English string identical to the value listed in Task 8's en.json entry. The test only depends on `EmitSubcommand` / `emit(store:)`, not on L10n. + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter UserMemoryCommandTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Commands/UserMemoryCommand.swift Tests/OrreryTests/UserMemoryCommandTests.swift +git commit -m "[FEAT] orrery memory user subcommand skeleton (info/path/emit/export)" +``` + +--- + +### Task 7: Rename project subcommands and wire `user` under `MemoryCommand` + +This is the **breaking change** — existing `orrery memory info/export/isolate/share/storage` become `orrery memory project info/...`. No aliases. + +**Files:** +- Modify: `Sources/OrreryCore/Commands/MemoryCommand.swift` +- Create: `Tests/OrreryTests/MemoryCommandStructureTests.swift` + +- [ ] **Step 1: Write the failing test** + +Create `Tests/OrreryTests/MemoryCommandStructureTests.swift`: + +```swift +import Testing +import ArgumentParser +@testable import OrreryCore + +@Suite("MemoryCommand structure") +struct MemoryCommandStructureTests { + + @Test("MemoryCommand has project and user subcommand groups") + func subgroupsPresent() { + let names = MemoryCommand.configuration.subcommands.map { $0._commandName } + #expect(names.contains("project")) + #expect(names.contains("user")) + } + + @Test("ProjectMemoryCommand exposes info/export/isolate/share/storage") + func projectSubcommandsExist() { + let names = MemoryCommand.ProjectSubcommand.configuration.subcommands.map { $0._commandName } + for expected in ["info", "export", "isolate", "share", "storage"] { + #expect(names.contains(expected), "missing subcommand: \(expected)") + } + } + + @Test("orrery memory no longer has top-level info subcommand") + func topLevelFlatRemoved() { + let names = MemoryCommand.configuration.subcommands.map { $0._commandName } + #expect(!names.contains("info")) + #expect(!names.contains("isolate")) + #expect(!names.contains("share")) + #expect(!names.contains("storage")) + #expect(!names.contains("export")) + } +} + +// Helper: ParsableCommand has no public `_commandName`; this extension +// surfaces the configured one for assertions. +extension ParsableCommand { + static var _commandName: String { configuration.commandName ?? "\(self)".lowercased() } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter MemoryCommandStructureTests +``` + +Expected: FAIL — `ProjectSubcommand` undefined, and top-level still has `info` etc. + +- [ ] **Step 3: Restructure `MemoryCommand`** + +In `Sources/OrreryCore/Commands/MemoryCommand.swift`: + +1. Wrap the existing `InfoSubcommand`, `ExportSubcommand`, `IsolateSubcommand`, `ShareSubcommand`, `StorageSubcommand` inside a new `public struct ProjectSubcommand: ParsableCommand` namespace with `commandName: "project"` and `subcommands: [...]` listing them. +2. Change `MemoryCommand.configuration.subcommands` to `[ProjectSubcommand.self, UserMemoryCommand.self]` (drop the old flat list). +3. Keep `MemoryCommand.run()` as the interactive top-level menu, but rebuild the menu to show two layers' status and route to either `ProjectSubcommand`'s interactive menu (extracted into `ProjectSubcommand.runInteractive()`) or `UserMemoryCommand`'s interactive flow (added in Task 13). + +For the interactive `run()`, replace the body with: + +```swift +public func run() throws { + let store = EnvironmentStore.default + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + + let projectDir = store.memoryDir(projectKey: projectKey, envName: envName) + let userDir = store.userMemoryDir() + let userEnabled = currentEnvShareUserMemory(store: store, envName: envName) + + print(L10n.Memory.summaryProject(projectDir.path)) + print(L10n.Memory.summaryUser(userEnabled, userDir.path)) + print("") + + let selector = SingleSelect( + title: L10n.Memory.topLevelPrompt, + options: [L10n.Memory.manageProject, L10n.Memory.manageUser], + selected: 0 + ) + switch selector.run() { + case 0: + var p = MemoryCommand.ProjectSubcommand() + try p.run() + case 1: + var u = UserMemoryCommand() + try u.run() + default: + break + } +} + +private func currentEnvShareUserMemory(store: EnvironmentStore, envName: String?) -> Bool { + guard let envName else { return true } + if envName == ReservedEnvironment.defaultName { + return store.loadOriginConfig().shareUserMemory + } + return (try? store.load(named: envName))?.shareUserMemory ?? true +} +``` + +The old `MemoryCommand.run()` interactive code (action menu listing isolate/share/storage) moves into `ProjectSubcommand.run()` verbatim. + +- [ ] **Step 4: Run test to verify it passes** + +``` +swift test --filter MemoryCommandStructureTests +``` + +Expected: PASS. Also run the full test suite to check no callers broke: + +``` +swift test +``` + +(Allow other failures only if they relate to L10n keys still missing — those are addressed in Task 8.) + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Commands/MemoryCommand.swift Tests/OrreryTests/MemoryCommandStructureTests.swift +git commit -m "[BREAKING] regroup orrery memory subcommands under project/user namespaces" +``` + +--- + +### Task 8: L10n keys for memory restructure + +**Files:** +- Modify: `Sources/OrreryCore/Resources/Localization/en.json` +- Modify: `Sources/OrreryCore/Resources/Localization/ja.json` +- Modify: `Sources/OrreryCore/Resources/Localization/zh-Hant.json` +- Modify: `Sources/OrreryCore/Resources/Localization/l10n-signatures.json` + +This task is straightforward data entry, but the L10n codegen plugin builds at compile time, so the build will fail if any referenced key is missing. + +- [ ] **Step 1: Add the new keys** + +In each locale JSON file, under the `Memory` group, add (English example shown — translate equivalents for ja and zh-Hant): + +```json +"summaryProject": { "$args": "string", "en": "Project memory: %@" }, +"summaryUser": { "$args": "bool,string", "en": "User memory: %@ (%@)" }, +"topLevelPrompt": { "en": "What would you like to manage?" }, +"manageProject": { "en": "Project memory" }, +"manageUser": { "en": "User memory" } +``` + +(`summaryUser`'s first `%@` is a Bool — Orrery's L10n encodes Bool via "enabled"/"disabled" strings; follow the existing isolate-shared pattern in `Memory.statusMode`.) + +Add a new `UserMemory` group at the top level (sibling of `Memory`): + +```json +"UserMemory": { + "abstract": { "en": "Manage user-global Orrery memory (cross-project, cross-env)." }, + "infoAbstract": { "en": "Show user memory location and status." }, + "pathAbstract": { "en": "Print the user memory directory path." }, + "emitAbstract": { "en": "Print MEMORY.md to stdout. Used by SessionStart hooks; not for humans." }, + "exportAbstract": { "en": "Export user MEMORY.md to a file." }, + "exportOutputHelp": { "en": "Output file path (default: USER_MEMORY.md)." }, + "enableAbstract": { "en": "Enable user memory in the current env (installs hooks)." }, + "disableAbstract": { "en": "Disable user memory in the current env (removes hooks)." }, + "statusPath": { "$args": "string", "en": "Path: %@" }, + "statusExists": { "$args": "bool,int", "en": "MEMORY.md exists: %@, size: %d bytes" }, + "noMemory": { "en": "No user memory to export." }, + "exported": { "$args": "string", "en": "Exported to %@" } +} +``` + +Re-run the L10n codegen tool (it runs as part of `swift build`): + +``` +swift build +``` + +`l10n-signatures.json` is regenerated by the build plugin — commit the regenerated version. + +- [ ] **Step 2: Replace hard-coded strings** + +Open `Sources/OrreryCore/Commands/UserMemoryCommand.swift` from Task 6 and replace the hard-coded English strings with their `L10n.UserMemory.*` equivalents now that the keys exist. + +- [ ] **Step 3: Build and run all tests** + +``` +swift build +swift test +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add Sources/OrreryCore/Resources/Localization/ Sources/OrreryCore/Commands/UserMemoryCommand.swift +git commit -m "[FEAT] L10n keys for orrery memory restructure + user-memory subcommands" +``` + +--- + +### Task 9: Refactor `MCPServer` onto `MemoryStore` + +**Files:** +- Modify: `Sources/OrreryCore/MCP/MCPServer.swift` + +No new behavior — pure refactor so the existing project-memory MCP tools route through `MemoryStore`. This sets up Task 10 / 11. + +- [ ] **Step 1: Replace the read/write helpers** + +In `Sources/OrreryCore/MCP/MCPServer.swift`, replace the `readMemory`, `writeMemory`, `writeFragment`, `cleanupFragments`, `pendingFragments` private helpers (and the inner `Fragment` struct) with a thin wrapper that delegates to `MemoryStore`: + +```swift +private static func projectMemoryStore() -> MemoryStore { + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + let dir = EnvironmentStore.default.memoryDir(projectKey: projectKey, envName: envName) + return MemoryStore(directory: dir) +} + +private static func readMemory() -> [String: Any] { + ensureClaudeSymlink() + let store = projectMemoryStore() + let result = (try? store.read()) ?? .init(memory: "", fragments: []) + + var content = result.memory + if !result.fragments.isEmpty { + content += "\n\n---\n## Pending Memory Fragments (from sync)\n" + content += "The following fragments were synced from other machines and need to be integrated.\n" + content += "Please consolidate them into the memory above, then write back with append=false.\n" + content += "After integration, the fragment files will be cleaned up automatically.\n\n" + for f in result.fragments { + content += "### \(f.filename)\n" + content += f.content + "\n\n" + } + } + + if content.isEmpty { + return [ + "content": [["type": "text", "text": "(no shared memory yet)"]], + "isError": false + ] + } + return [ + "content": [["type": "text", "text": content]], + "isError": false + ] +} + +private static func writeMemory(content: String, append: Bool) -> [String: Any] { + ensureClaudeSymlink() + let store = projectMemoryStore() + do { + try store.write(content: content, append: append) + return [ + "content": [["type": "text", "text": "Memory updated: \(store.directory.appendingPathComponent("MEMORY.md").path)"]], + "isError": false + ] + } catch { + return toolError("Failed to write memory: \(error.localizedDescription)") + } +} +``` + +Delete the now-unused helpers (`sharedMemoryFile`, `fragmentsDirectory`, `peerName`, `writeFragment`, `cleanupFragments`, `pendingFragments`, the inner `Fragment` struct). + +Keep `sharedMemoryDirectory()` (still used by `ensureClaudeSymlink`) and the symlink path computation. + +- [ ] **Step 2: Build and run tests** + +``` +swift build +swift test +``` + +Expected: PASS (existing MCP-related tests, if any, should still work). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/OrreryCore/MCP/MCPServer.swift +git commit -m "[REFACTOR] route MCPServer project-memory tools through MemoryStore" +``` + +--- + +### Task 10: Register `orrery_user_memory_read` MCP tool + +**Files:** +- Modify: `Sources/OrreryCore/MCP/MCPServer.swift` + +- [ ] **Step 1: Add user-memory helper + register tool** + +In `Sources/OrreryCore/MCP/MCPServer.swift`, near the project-memory helpers, add: + +```swift +private static func userMemoryStore() -> MemoryStore { + MemoryStore(directory: EnvironmentStore.default.userMemoryDir()) +} + +private static func readUserMemory() -> [String: Any] { + let store = userMemoryStore() + let result = (try? store.read()) ?? .init(memory: "", fragments: []) + + var content = result.memory + if !result.fragments.isEmpty { + content += "\n\n---\n## Pending Memory Fragments (from sync)\n" + content += "The following fragments were synced from other machines and need to be integrated.\n" + content += "Please consolidate them into the memory above, then write back with append=false.\n" + content += "After integration, the fragment files will be cleaned up automatically.\n\n" + for f in result.fragments { + content += "### \(f.filename)\n" + content += f.content + "\n\n" + } + } + + if content.isEmpty { + return [ + "content": [["type": "text", "text": "(no user-global memory yet)"]], + "isError": false + ] + } + return [ + "content": [["type": "text", "text": content]], + "isError": false + ] +} +``` + +In the `tools/list` tool definitions block (around the existing `orrery_memory_read` entry), append: + +```swift +[ + "name": "orrery_user_memory_read", + "description": "Read the user-global Orrery memory. This memory follows you across all projects and all environments — use it for facts about who you are (the user), cross-project preferences, and tool/account references. Always read before writing to avoid overwriting existing knowledge. If pending sync fragments are present, consolidate them into MEMORY.md and write back with append=false.", + "inputSchema": [ + "type": "object", + "properties": [:] as [String: Any], + "additionalProperties": false + ] +], +``` + +In the `tools/call` dispatch switch, add: + +```swift +case "orrery_user_memory_read": + return readUserMemory() +``` + +- [ ] **Step 2: Build** + +``` +swift build +``` + +Expected: succeeds. + +- [ ] **Step 3: Sanity check the tool list** + +``` +swift run orrery-bin mcp-server <<<'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | head -200 +``` + +(Or simply skip the runtime check — the build alone is a strong signal.) Expected: includes `orrery_user_memory_read` in the output. + +- [ ] **Step 4: Commit** + +```bash +git add Sources/OrreryCore/MCP/MCPServer.swift +git commit -m "[FEAT] register orrery_user_memory_read MCP tool" +``` + +--- + +### Task 11: Register `orrery_user_memory_write` MCP tool + +**Files:** +- Modify: `Sources/OrreryCore/MCP/MCPServer.swift` + +- [ ] **Step 1: Add write helper + register tool** + +```swift +private static func writeUserMemory(content: String, append: Bool) -> [String: Any] { + let store = userMemoryStore() + do { + try store.write(content: content, append: append) + return [ + "content": [["type": "text", "text": "User memory updated: \(store.directory.appendingPathComponent("MEMORY.md").path)"]], + "isError": false + ] + } catch { + return toolError("Failed to write user memory: \(error.localizedDescription)") + } +} +``` + +Add tool definition: + +```swift +[ + "name": "orrery_user_memory_write", + "description": "Write or append to the user-global Orrery memory. This persists across all projects/envs. Use for: user role/preferences, cross-project feedback rules, tool/account references. Default is append; set append=false to rewrite (used after consolidating fragments).", + "inputSchema": [ + "type": "object", + "properties": [ + "content": [ + "type": "string", + "description": "Markdown content to write to user-global memory" + ], + "append": [ + "type": "boolean", + "description": "If true, append to existing memory. If false, overwrite. Default: true" + ] + ], + "required": ["content"] + ] +], +``` + +Add dispatch case: + +```swift +case "orrery_user_memory_write": + let args = (params["arguments"] as? [String: Any]) ?? [:] + let content = (args["content"] as? String) ?? "" + let append = (args["append"] as? Bool) ?? true + return writeUserMemory(content: content, append: append) +``` + +- [ ] **Step 2: Build** + +``` +swift build +``` + +Expected: succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add Sources/OrreryCore/MCP/MCPServer.swift +git commit -m "[FEAT] register orrery_user_memory_write MCP tool" +``` + +--- + +## Phase C — Hook installers + +### Task 12: `UserMemoryHookInstaller` protocol + Claude installer + +**Files:** +- Create: `Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift` +- Create: `Tests/OrreryTests/UserMemoryHookInstallerTests.swift` + +- [ ] **Step 1: Write failing tests for Claude installer** + +Create `Tests/OrreryTests/UserMemoryHookInstallerTests.swift`: + +```swift +import Testing +import Foundation +@testable import OrreryCore + +@Suite("ClaudeHookInstaller") +struct ClaudeHookInstallerTests { + let tmpDir: URL + + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-claudehook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("install on empty config creates settings.json with our hook entry") + func installEmpty() throws { + let installer = ClaudeHookInstaller() + try installer.install(at: tmpDir) + let settings = tmpDir.appendingPathComponent("settings.json") + let body = try String(contentsOf: settings, encoding: .utf8) + #expect(body.contains("\"command\"")) + #expect(body.contains("orrery memory user emit")) + #expect(body.contains("\"_orrery_managed\"")) + } + + @Test("install is idempotent") + func installIdempotent() throws { + let installer = ClaudeHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + let settings = tmpDir.appendingPathComponent("settings.json") + let data = try Data(contentsOf: settings) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + let sessionStart = hooks["SessionStart"] as! [[String: Any]] + let firstMatcher = sessionStart[0] + let entries = firstMatcher["hooks"] as! [[String: Any]] + let managed = entries.filter { ($0["_orrery_managed"] as? Bool) == true } + #expect(managed.count == 1) + } + + @Test("install preserves foreign hook entries") + func installPreservesForeign() throws { + let settings = tmpDir.appendingPathComponent("settings.json") + let foreign: [String: Any] = [ + "hooks": [ + "SessionStart": [ + [ + "matcher": "*", + "hooks": [ + ["type": "command", "command": "echo something-else"] + ] + ] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: foreign, options: [.prettyPrinted]) + try data.write(to: settings) + + try ClaudeHookInstaller().install(at: tmpDir) + + let updated = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + let hooks = updated["hooks"] as! [String: Any] + let sessionStart = hooks["SessionStart"] as! [[String: Any]] + let entries = sessionStart[0]["hooks"] as! [[String: Any]] + #expect(entries.count == 2) + let commands = entries.compactMap { $0["command"] as? String } + #expect(commands.contains("echo something-else")) + #expect(commands.contains("orrery memory user emit")) + } + + @Test("remove only deletes _orrery_managed entries") + func removeKeepsForeign() throws { + let settings = tmpDir.appendingPathComponent("settings.json") + try ClaudeHookInstaller().install(at: tmpDir) + // Inject a foreign entry next to ours + var json = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + var hooks = json["hooks"] as! [String: Any] + var sessionStart = hooks["SessionStart"] as! [[String: Any]] + var entries = sessionStart[0]["hooks"] as! [[String: Any]] + entries.append(["type": "command", "command": "echo foreign"]) + sessionStart[0]["hooks"] = entries + hooks["SessionStart"] = sessionStart + json["hooks"] = hooks + try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) + .write(to: settings) + + try ClaudeHookInstaller().remove(at: tmpDir) + + let final = try JSONSerialization.jsonObject(with: try Data(contentsOf: settings)) as! [String: Any] + let finalHooks = final["hooks"] as! [String: Any] + let finalSessionStart = finalHooks["SessionStart"] as! [[String: Any]] + let finalEntries = finalSessionStart[0]["hooks"] as! [[String: Any]] + #expect(finalEntries.count == 1) + #expect((finalEntries[0]["command"] as? String) == "echo foreign") + } + + @Test("isInstalled true after install, false after remove") + func isInstalledStatus() throws { + let installer = ClaudeHookInstaller() + #expect(!installer.isInstalled(at: tmpDir)) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter ClaudeHookInstallerTests +``` + +Expected: FAIL — `ClaudeHookInstaller` undefined. + +- [ ] **Step 3: Implement protocol + Claude installer** + +Create `Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift`: + +```swift +import Foundation + +public protocol UserMemoryHookInstaller { + /// Idempotently add the user-memory SessionStart hook entry to this tool's config. + func install(at configDir: URL) throws + /// Remove only entries with `_orrery_managed: true`. + func remove(at configDir: URL) throws + /// Whether the managed entry is currently present. + func isInstalled(at configDir: URL) -> Bool +} + +/// Marker key the installers stamp on every entry they manage, so `remove` can +/// tell our hooks apart from user-installed ones. +let OrreryManagedKey = "_orrery_managed" +let UserMemoryHookCommand = "orrery memory user emit" + +/// Shared JSON-merge logic used by all three installers — Claude, Codex (hooks.json), +/// Gemini all read JSON files with the same `hooks.SessionStart[*].hooks[*]` shape. +struct JSONHookEditor { + let settingsFile: URL + + func loadOrEmpty() throws -> [String: Any] { + let fm = FileManager.default + guard fm.fileExists(atPath: settingsFile.path) else { return [:] } + let data = try Data(contentsOf: settingsFile) + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + func save(_ root: [String: Any]) throws { + let fm = FileManager.default + try fm.createDirectory(at: settingsFile.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: settingsFile, options: .atomic) + } + + /// Returns `(root, sessionStart, firstMatcherIndex)` after ensuring shape exists. + func ensureSessionStartShape(in root: inout [String: Any]) -> Int { + var hooks = (root["hooks"] as? [String: Any]) ?? [:] + var sessionStart = (hooks["SessionStart"] as? [[String: Any]]) ?? [] + if sessionStart.isEmpty { + sessionStart.append(["matcher": "*", "hooks": [[String: Any]]()]) + } else if sessionStart[0]["hooks"] == nil { + sessionStart[0]["hooks"] = [[String: Any]]() + } + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + return 0 + } + + func install() throws { + var root = try loadOrEmpty() + _ = ensureSessionStartShape(in: &root) + var hooks = root["hooks"] as! [String: Any] + var sessionStart = hooks["SessionStart"] as! [[String: Any]] + var entries = sessionStart[0]["hooks"] as! [[String: Any]] + + let alreadyPresent = entries.contains { + ($0[OrreryManagedKey] as? Bool) == true && + ($0["command"] as? String) == UserMemoryHookCommand + } + if !alreadyPresent { + entries.append([ + "type": "command", + "command": UserMemoryHookCommand, + OrreryManagedKey: true + ]) + } + sessionStart[0]["hooks"] = entries + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + try save(root) + } + + func remove() throws { + var root = try loadOrEmpty() + guard var hooks = root["hooks"] as? [String: Any], + var sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return } + for i in sessionStart.indices { + if var entries = sessionStart[i]["hooks"] as? [[String: Any]] { + entries.removeAll { ($0[OrreryManagedKey] as? Bool) == true } + sessionStart[i]["hooks"] = entries + } + } + hooks["SessionStart"] = sessionStart + root["hooks"] = hooks + try save(root) + } + + func isInstalled() -> Bool { + guard let root = try? loadOrEmpty(), + let hooks = root["hooks"] as? [String: Any], + let sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return false } + for matcher in sessionStart { + let entries = (matcher["hooks"] as? [[String: Any]]) ?? [] + if entries.contains(where: { + ($0[OrreryManagedKey] as? Bool) == true && + ($0["command"] as? String) == UserMemoryHookCommand + }) { + return true + } + } + return false + } +} + +public struct ClaudeHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).isInstalled() + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +swift test --filter ClaudeHookInstallerTests +``` + +Expected: all 5 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift Tests/OrreryTests/UserMemoryHookInstallerTests.swift +git commit -m "[FEAT] UserMemoryHookInstaller protocol + ClaudeHookInstaller" +``` + +--- + +### Task 13: `CodexHookInstaller` and `GeminiHookInstaller` + +**Files:** +- Modify: `Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift` +- Modify: `Tests/OrreryTests/UserMemoryHookInstallerTests.swift` + +**Pre-task verification:** The plan assumes Codex `hooks.json` and Gemini `settings.json` use the *same* `hooks.SessionStart[*].hooks[*]` JSON shape Claude uses. Before writing implementation code, **verify this against the current Codex CLI hook reference and Gemini CLI hook reference docs**. If the shape differs (e.g., camelCase key, flat list, different nesting), adjust `JSONHookEditor` to accept a per-tool config object describing the keys, and pass that into each installer's `init`. The test asserts (file location + idempotency + removability) stay the same regardless of schema. + +- [ ] **Step 1: Write failing tests** + +Append to `UserMemoryHookInstallerTests.swift`: + +```swift +@Suite("CodexHookInstaller") +struct CodexHookInstallerTests { + let tmpDir: URL + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-codexhook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("Codex installer targets hooks.json, not config.toml") + func codexTargetsHooksJSON() throws { + try CodexHookInstaller().install(at: tmpDir) + #expect(FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("hooks.json").path)) + #expect(!FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("config.toml").path)) + } + + @Test("Codex installer is idempotent and removable") + func codexLifecycle() throws { + let installer = CodexHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} + +@Suite("GeminiHookInstaller") +struct GeminiHookInstallerTests { + let tmpDir: URL + init() throws { + tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-geminihook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } + + @Test("Gemini installer targets settings.json in configDir") + func geminiTargetsSettings() throws { + try GeminiHookInstaller().install(at: tmpDir) + #expect(FileManager.default.fileExists(atPath: tmpDir.appendingPathComponent("settings.json").path)) + } + + @Test("Gemini installer is idempotent and removable") + func geminiLifecycle() throws { + let installer = GeminiHookInstaller() + try installer.install(at: tmpDir) + try installer.install(at: tmpDir) + #expect(installer.isInstalled(at: tmpDir)) + try installer.remove(at: tmpDir) + #expect(!installer.isInstalled(at: tmpDir)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +swift test --filter "Hook" +``` + +Expected: FAIL on Codex / Gemini suites. + +- [ ] **Step 3: Implement the two installers** + +Append to `UserMemoryHookInstaller.swift`: + +```swift +public struct CodexHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("hooks.json")).isInstalled() + } +} + +public struct GeminiHookInstaller: UserMemoryHookInstaller { + public init() {} + public func install(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).install() + } + public func remove(at configDir: URL) throws { + try JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).remove() + } + public func isInstalled(at configDir: URL) -> Bool { + JSONHookEditor(settingsFile: configDir.appendingPathComponent("settings.json")).isInstalled() + } +} + +/// Returns the installer for `tool`. Add new tools here as they gain SessionStart support. +public func userMemoryHookInstaller(for tool: Tool) -> UserMemoryHookInstaller { + switch tool { + case .claude: return ClaudeHookInstaller() + case .codex: return CodexHookInstaller() + case .gemini: return GeminiHookInstaller() + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +swift test --filter "Hook" +``` + +Expected: all 9 hook tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Setup/UserMemoryHookInstaller.swift Tests/OrreryTests/UserMemoryHookInstallerTests.swift +git commit -m "[FEAT] CodexHookInstaller (hooks.json) + GeminiHookInstaller (settings.json)" +``` + +--- + +### Task 14: `EnvironmentStore.ensureUserMemoryHooks` / `removeUserMemoryHooks` + +**Files:** +- Modify: `Sources/OrreryCore/Storage/EnvironmentStore.swift` +- Modify: `Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift` + +- [ ] **Step 1: Write failing tests** + +Append to `EnvironmentStoreUserMemoryTests.swift`: + +```swift +@Test("ensureUserMemoryHooks installs hooks for each installed tool") +func ensureInstallsForEachTool() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-ensurehooks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + + var env = OrreryEnvironment(name: "e1", tools: [.claude, .codex]) + try store.save(env) + // Pre-create tool config dirs so the installers have a place to write. + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e1") + let codexDir = store.toolConfigDir(tool: .codex, environment: "e1") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e1") + + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) + #expect(CodexHookInstaller().isInstalled(at: codexDir)) +} + +@Test("ensureUserMemoryHooks skips installation when shareUserMemory is false") +func ensureSkipsWhenDisabled() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-ensurehooks-off-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e2", tools: [.claude], shareUserMemory: false) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e2") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e2") + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) +} + +@Test("removeUserMemoryHooks removes from all tools") +func removeFromAllTools() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-removehooks-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e3", tools: [.claude, .codex]) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e3") + let codexDir = store.toolConfigDir(tool: .codex, environment: "e3") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + + try store.ensureUserMemoryHooks(for: "e3") + try store.removeUserMemoryHooks(for: "e3") + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) + #expect(!CodexHookInstaller().isInstalled(at: codexDir)) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +swift test --filter EnvironmentStoreUserMemoryTests +``` + +Expected: FAIL. + +- [ ] **Step 3: Add the methods** + +In `Sources/OrreryCore/Storage/EnvironmentStore.swift`, near `linkOrreryMemory`, add: + +```swift +/// Install the user-memory SessionStart hook into each tool config dir of this env, +/// but only if `env.shareUserMemory == true`. Idempotent. +public func ensureUserMemoryHooks(for envName: String) throws { + let share: Bool + let tools: [Tool] + if envName == ReservedEnvironment.defaultName { + share = loadOriginConfig().shareUserMemory + tools = Tool.allCases.filter { isOriginManaged(tool: $0) } + } else { + let env = try load(named: envName) + share = env.shareUserMemory + tools = env.tools + } + guard share else { return } + for tool in tools { + let dir = (envName == ReservedEnvironment.defaultName) + ? originConfigDir(tool: tool) + : toolConfigDir(tool: tool, environment: envName) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try userMemoryHookInstaller(for: tool).install(at: dir) + } +} + +/// Remove the managed hook entry from each tool config dir of this env. +public func removeUserMemoryHooks(for envName: String) throws { + let tools: [Tool] + if envName == ReservedEnvironment.defaultName { + tools = Tool.allCases.filter { isOriginManaged(tool: $0) } + } else { + tools = (try load(named: envName)).tools + } + for tool in tools { + let dir = (envName == ReservedEnvironment.defaultName) + ? originConfigDir(tool: tool) + : toolConfigDir(tool: tool, environment: envName) + guard FileManager.default.fileExists(atPath: dir.path) else { continue } + try userMemoryHookInstaller(for: tool).remove(at: dir) + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +swift test --filter EnvironmentStoreUserMemoryTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Storage/EnvironmentStore.swift Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift +git commit -m "[FEAT] EnvironmentStore.ensureUserMemoryHooks/removeUserMemoryHooks" +``` + +--- + +### Task 15: Wire `enable` / `disable` subcommands + +**Files:** +- Modify: `Sources/OrreryCore/Commands/UserMemoryCommand.swift` +- Modify: `Tests/OrreryTests/UserMemoryCommandTests.swift` + +- [ ] **Step 1: Write failing tests** + +Append to `UserMemoryCommandTests.swift`: + +```swift +@Test("enable sets shareUserMemory=true and installs hooks for current env") +func enableInstallsHooks() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-enable-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [.claude], shareUserMemory: false) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + + try UserMemoryCommand.applyEnable(envName: "e", store: store) + + let updated = try store.load(named: "e") + #expect(updated.shareUserMemory == true) + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) +} + +@Test("disable sets shareUserMemory=false and removes hooks") +func disableRemovesHooks() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-disable-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [.claude], shareUserMemory: true) + try store.save(env) + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try store.ensureUserMemoryHooks(for: "e") + + try UserMemoryCommand.applyDisable(envName: "e", store: store) + + let updated = try store.load(named: "e") + #expect(updated.shareUserMemory == false) + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +swift test --filter UserMemoryCommandTests +``` + +Expected: FAIL — `applyEnable` / `applyDisable` undefined. + +- [ ] **Step 3: Implement the helpers + flesh out enable/disable subcommands** + +In `Sources/OrreryCore/Commands/UserMemoryCommand.swift`, replace the placeholder `EnableSubcommand` / `DisableSubcommand` bodies and add the testable helpers: + +```swift +public static func applyEnable(envName: String, store: EnvironmentStore) throws { + if envName == ReservedEnvironment.defaultName { + var c = store.loadOriginConfig() + c.shareUserMemory = true + try store.saveOriginConfig(c) + } else { + var env = try store.load(named: envName) + env.shareUserMemory = true + try store.save(env) + } + try store.ensureUserMemoryHooks(for: envName) +} + +public static func applyDisable(envName: String, store: EnvironmentStore) throws { + if envName == ReservedEnvironment.defaultName { + var c = store.loadOriginConfig() + c.shareUserMemory = false + try store.saveOriginConfig(c) + } else { + var env = try store.load(named: envName) + env.shareUserMemory = false + try store.save(env) + } + try store.removeUserMemoryHooks(for: envName) +} +``` + +Update `EnableSubcommand.run()`: + +```swift +public func run() throws { + guard let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] else { + throw ValidationError(L10n.UserMemory.noActiveEnv) + } + try UserMemoryCommand.applyEnable(envName: envName, store: .default) + print(L10n.UserMemory.enabled(envName)) +} +``` + +Update `DisableSubcommand.run()`: + +```swift +public func run() throws { + guard let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] else { + throw ValidationError(L10n.UserMemory.noActiveEnv) + } + try UserMemoryCommand.applyDisable(envName: envName, store: .default) + print(L10n.UserMemory.disabled(envName)) +} +``` + +Add the three new L10n keys to `en.json` / `ja.json` / `zh-Hant.json` under `UserMemory`: + +```json +"noActiveEnv": { "en": "No active environment (set ORRERY_ACTIVE_ENV or run inside orrery use)." }, +"enabled": { "$args": "string", "en": "User memory enabled for %@" }, +"disabled": { "$args": "string", "en": "User memory disabled for %@" } +``` + +- [ ] **Step 4: Run tests** + +``` +swift build +swift test --filter UserMemoryCommandTests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Commands/UserMemoryCommand.swift Tests/OrreryTests/UserMemoryCommandTests.swift Sources/OrreryCore/Resources/Localization/ +git commit -m "[FEAT] orrery memory user enable/disable wired through EnvironmentStore" +``` + +--- + +### Task 16: `_reconcile-user-memory-hooks` internal command + `orrery use` integration + +**Files:** +- Create: `Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift` +- Modify: `Sources/OrreryCore/Commands/OrreryCommand.swift` (register) +- Modify: `Sources/OrreryCore/Shell/ShellFunctionGenerator.swift` (call it in `orrery use`) +- Modify: `Tests/OrreryTests/ShellFunctionGeneratorTests.swift` + +- [ ] **Step 1: Implement the internal command** + +Create `Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift`: + +```swift +import ArgumentParser +import Foundation + +/// Internal: reconcile each tool's settings.json so the SessionStart hook +/// matches the current env's `shareUserMemory` flag. Called from the shell +/// `use` function after the env vars are exported. +public struct ReconcileUserMemoryHooksCommand: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "_reconcile-user-memory-hooks", + abstract: "Internal: ensure user-memory SessionStart hooks match the active env's shareUserMemory state.", + shouldDisplay: false + ) + + public init() {} + + public func run() throws { + let envName = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"] + ?? ReservedEnvironment.defaultName + let store = EnvironmentStore.default + + let share: Bool + if envName == ReservedEnvironment.defaultName { + share = store.loadOriginConfig().shareUserMemory + } else { + share = (try? store.load(named: envName))?.shareUserMemory ?? true + } + + if share { + try? store.ensureUserMemoryHooks(for: envName) + } else { + try? store.removeUserMemoryHooks(for: envName) + } + } +} +``` + +In `Sources/OrreryCore/Commands/OrreryCommand.swift`, add `ReconcileUserMemoryHooksCommand.self` to the `subcommands:` array. + +- [ ] **Step 2: Patch the shell function** + +In `Sources/OrreryCore/Shell/ShellFunctionGenerator.swift`, find the `_orrery_init` function and the existing `_link-memory` call: + +```sh +# Ensure the Orrery memory directory is linked into Claude's auto-memory location +command orrery-bin _link-memory 2>/dev/null || true +``` + +Add a sibling call **before** that comment block (so reconciliation runs before symlinking): + +```sh +# Reconcile user-memory SessionStart hooks for the active env. +command orrery-bin _reconcile-user-memory-hooks 2>/dev/null || true +``` + +Also patch the `use)` case in the dispatch. Locate it inside the multi-line string returned by `generate(...)` — grep for `use)` to find it. There are two branches that set `ORRERY_ACTIVE_ENV`: one for `orrery use origin` (default branch) and one for `orrery use ` (explicit env). Immediately **after each** `export ORRERY_ACTIVE_ENV=...` line, insert: + +```sh +command orrery-bin _reconcile-user-memory-hooks 2>/dev/null || true +``` + +So every `orrery use` invocation reconciles before returning. + +- [ ] **Step 3: Test shell function inclusion** + +Append to `Tests/OrreryTests/ShellFunctionGeneratorTests.swift`: + +```swift +@Test("generated shell function calls _reconcile-user-memory-hooks") +func shellCallsReconcile() { + let out = ShellFunctionGenerator.generate(version: "9.9.9") + #expect(out.contains("_reconcile-user-memory-hooks")) +} +``` + +- [ ] **Step 4: Build and run** + +``` +swift build +swift test --filter ShellFunctionGenerator +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Commands/ReconcileUserMemoryHooksCommand.swift Sources/OrreryCore/Commands/OrreryCommand.swift Sources/OrreryCore/Shell/ShellFunctionGenerator.swift Tests/OrreryTests/ShellFunctionGeneratorTests.swift +git commit -m "[FEAT] _reconcile-user-memory-hooks command + shell use integration" +``` + +--- + +## Phase D — Wizard & Setup + +### Task 17: Wizard adds user-memory question to `CreateCommand` + +**Files:** +- Modify: `Sources/OrreryCore/Commands/CreateCommand.swift` +- Modify: `Sources/OrreryCore/Resources/Localization/*.json` +- Modify: `Tests/OrreryTests/CreateCommandTests.swift` + +- [ ] **Step 1: Write failing test** + +Append to `Tests/OrreryTests/CreateCommandTests.swift`: + +```swift +@Test("createEnvironment with shareUserMemory=false persists the flag") +func createPersistsShareUserMemoryFalse() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-create-shareuser-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try CreateCommand.createEnvironment( + name: "demo", + description: "", + tool: .claude, + isolateSessions: false, + isolateMemory: false, + shareUserMemory: false, + store: store + ) + let env = try store.load(named: "demo") + #expect(env.shareUserMemory == false) +} + +@Test("createEnvironment defaults shareUserMemory to true") +func createDefaultsShareUserMemory() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-create-default-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try CreateCommand.createEnvironment( + name: "demo", + description: "", + tool: .claude, + store: store + ) + let env = try store.load(named: "demo") + #expect(env.shareUserMemory == true) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter "CreateCommand" +``` + +Expected: FAIL — `createEnvironment` doesn't accept `shareUserMemory`. + +- [ ] **Step 3: Update `createEnvironment` signature** + +In `Sources/OrreryCore/Commands/CreateCommand.swift`: + +```swift +public static func createEnvironment( + name: String, + description: String, + tool: Tool, + isolateSessions: Bool = false, + isolateMemory: Bool = false, + shareUserMemory: Bool = true, + store: EnvironmentStore +) throws { + let env = OrreryEnvironment( + name: name, + description: description, + isolatedSessionTools: isolateSessions ? [tool] : [], + isolateMemory: isolateMemory, + shareUserMemory: shareUserMemory + ) + try store.save(env) + try store.addTool(tool, to: name) + + if tool == .claude { + let projectKey = FileManager.default.currentDirectoryPath + .replacingOccurrences(of: "/", with: "-") + let claudeConfigDir = store.toolConfigDir(tool: .claude, environment: name) + store.linkOrreryMemory(projectKey: projectKey, envName: name, claudeConfigDir: claudeConfigDir) + } + + if shareUserMemory { + try store.ensureUserMemoryHooks(for: name) + } +} +``` + +Also add an `@Flag` to the CLI struct (under `isolateMemory`): + +```swift +@Flag(name: .long, help: ArgumentHelp(L10n.Create.userMemoryDisableHelp), inversion: .prefixedNo) +public var userMemory: Bool = true +``` + +Wire it into the wizard / explicit-tool paths so the flag reaches `ToolSetupRunner.runWizard` and downstream — for the existing `run()` body, append after the wizard step: + +```swift +// Persist the (default-true) shareUserMemory flag onto the new env. +var saved = try store.load(named: name) +saved.shareUserMemory = userMemory +try store.save(saved) +if userMemory { + try? store.ensureUserMemoryHooks(for: name) +} +``` + +Add the wizard question to `runWizard(store:)` after the tool loop returns: + +```swift +let shareUserMemory = askShareUserMemory() +``` + +and surface it to callers (return a triple instead of a tuple — or add an instance var on the wizard helper; the cleanest is to capture into `CreateCommand` via a static var pattern, but to avoid global mutable state, add a new return value). + +Concretely, change the signature: + +```swift +static func runWizard(store: EnvironmentStore) -> ([ToolSetupRunner.Config], installStatusline: Bool, shareUserMemory: Bool) { + var configs: [ToolSetupRunner.Config] = [] + var installStatusline = false + for tool in Tool.allCases { + guard askSetupTool(tool.rawValue, defaultYes: tool == .claude) else { continue } + configs.append(ToolSetupRunner.runWizard(for: tool, store: store)) + if tool == .claude { + installStatusline = askInstallStatusline() + } + } + let shareUserMemory = askShareUserMemory() + return (configs, installStatusline, shareUserMemory) +} + +static func askShareUserMemory() -> Bool { + let selector = SingleSelect( + title: L10n.Create.askShareUserMemory, + options: [L10n.Create.shareUserMemoryYes, L10n.Create.shareUserMemoryNo], + selected: 0 + ) + return selector.run() == 0 +} +``` + +Update the caller in `run()`: + +```swift +let shareUserMemoryDefault: Bool +if let toolFlag = tool { + // unchanged path + shareUserMemoryDefault = userMemory + configs = [...] +} else { + let wizardResult = Self.runWizard(store: store) + configs = wizardResult.0 + installStatusline = wizardResult.1 + shareUserMemoryDefault = wizardResult.2 +} +``` + +Use `shareUserMemoryDefault` (instead of the `userMemory` flag) when writing back the env at the end. The `--no-user-memory` flag takes precedence: if explicitly false, force false. + +- [ ] **Step 4: Add L10n keys** + +In `Create` group of each locale JSON: + +```json +"askShareUserMemory": { "en": "Enable user memory (cross-project personal memory layer)?" }, +"shareUserMemoryYes": { "en": "Enable (recommended)" }, +"shareUserMemoryNo": { "en": "Disable for this env" }, +"userMemoryDisableHelp": { "en": "Disable user memory in this env (pass --no-user-memory; default: enabled)." } +``` + +- [ ] **Step 5: Build and run tests** + +``` +swift build +swift test --filter "CreateCommand" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/OrreryCore/Commands/CreateCommand.swift Sources/OrreryCore/Resources/Localization/ Tests/OrreryTests/CreateCommandTests.swift +git commit -m "[FEAT] env-create wizard + --no-user-memory flag, default enabled" +``` + +--- + +### Task 18: Origin setup wizard parity + +**Files:** +- Modify: `Sources/OrreryCore/Commands/SetupCommand.swift` (or wherever the origin wizard lives) +- Modify: `Tests/OrreryTests/SetupCommandTests.swift` + +- [ ] **Step 1: Locate the origin wizard** + +Inspect `SetupCommand.swift` to find where the origin-side memory questions are asked. The pattern mirrors `CreateCommand.runWizard`. Identify the analog of `isolateMemory` for origin, then append `askShareUserMemory()` immediately after. + +- [ ] **Step 2: Write failing test** + +In `SetupCommandTests.swift`, add a test that constructs an `OriginConfig` via the setup helper (or directly) with `shareUserMemory = false`, saves it, and then runs `ensureUserMemoryHooks(for: "origin")` — assert no hooks were installed. + +```swift +@Test("origin setup with shareUserMemory=false skips hook installation") +func originSkipsWhenDisabled() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-origin-su-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + try store.saveOriginConfig(OriginConfig(shareUserMemory: false)) + // Simulate a managed Claude + let claudeDir = store.originConfigDir(tool: .claude) + try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + // Pretend it's a takeover-managed symlink by creating the symlink (not strictly required + // because isOriginManaged is the test gate; mock by bypassing — use direct install instead). + try store.ensureUserMemoryHooks(for: ReservedEnvironment.defaultName) + #expect(!ClaudeHookInstaller().isInstalled(at: claudeDir)) +} +``` + +- [ ] **Step 3: Run test to verify it fails or passes** + +``` +swift test --filter SetupCommandTests +``` + +If the test already passes (because `ensureUserMemoryHooks` correctly gates on `shareUserMemory`), the gating is good — but the wizard still needs to ask the question. Verify by hand-reading `SetupCommand.swift`. If the wizard doesn't ask, write a separate test for the wizard helper. + +- [ ] **Step 4: Implement the missing wizard step** + +Add an analogous question + `shareUserMemory` parameter to the origin setup flow. Call `store.ensureUserMemoryHooks(for: ReservedEnvironment.defaultName)` after setup completes. + +- [ ] **Step 5: Build and run tests** + +``` +swift build +swift test +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/OrreryCore/Commands/SetupCommand.swift Tests/OrreryTests/SetupCommandTests.swift Sources/OrreryCore/Resources/Localization/ +git commit -m "[FEAT] origin setup wizard inherits user-memory toggle" +``` + +--- + +### Task 19: `addTool` installs hook on the new tool + +**Files:** +- Modify: `Sources/OrreryCore/Storage/EnvironmentStore.swift` +- Modify: `Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift` + +- [ ] **Step 1: Write failing test** + +Append: + +```swift +@Test("addTool installs user-memory hook on the new tool when shareUserMemory=true") +func addToolInstallsHook() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("orrery-addtoolhook-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + let store = EnvironmentStore(homeURL: tmp) + var env = OrreryEnvironment(name: "e", tools: [], shareUserMemory: true) + try store.save(env) + try store.addTool(.claude, to: "e") + let claudeDir = store.toolConfigDir(tool: .claude, environment: "e") + #expect(ClaudeHookInstaller().isInstalled(at: claudeDir)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +swift test --filter "addToolInstallsHook" +``` + +Expected: FAIL. + +- [ ] **Step 3: Modify `addTool`** + +In `Sources/OrreryCore/Storage/EnvironmentStore.swift`, find `addTool(_:to:)` (around line 117). After `try save(env)` at the end of the function, add: + +```swift +if env.shareUserMemory { + try? userMemoryHookInstaller(for: tool).install(at: toolDir) +} +``` + +- [ ] **Step 4: Run test** + +``` +swift test --filter "addToolInstallsHook" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Storage/EnvironmentStore.swift Tests/OrreryTests/EnvironmentStoreUserMemoryTests.swift +git commit -m "[FEAT] addTool installs user-memory hook when env opted in" +``` + +--- + +## Phase E — Release prep + +### Task 20: Version bump to 3.0.0 + +**Files:** +- Modify: `Sources/OrreryCore/Commands/OrreryCommand.swift:4` +- Modify: `Sources/OrreryCore/MCP/MCPServer.swift:467` (the `currentVersion()` literal, if any; otherwise it uses `OrreryVersion.current` already) +- Modify: `docs/index.html` (version badge) +- Modify: `docs/zh_TW.html` (version badge) + +- [ ] **Step 1: Bump `OrreryVersion`** + +In `Sources/OrreryCore/Commands/OrreryCommand.swift` line 4: + +```swift +public static let current = "3.0.0" +``` + +- [ ] **Step 2: Verify `MCPServer.currentVersion()` reads from `OrreryVersion`** + +Open `MCPServer.swift:467` — it should be `OrreryVersion.current`. If a hard-coded string exists anywhere else (`grep -rn "2.6.2" Sources`), update it. + +- [ ] **Step 3: Update HTML badges** + +In both `docs/index.html` and `docs/zh_TW.html`, locate the version badge (shield URL or text reference to `v2.6.2`) and replace with `v3.0.0`. + +- [ ] **Step 4: Build** + +``` +swift build +swift test +``` + +Expected: passes. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/OrreryCore/Commands/OrreryCommand.swift Sources/OrreryCore/MCP/MCPServer.swift docs/index.html docs/zh_TW.html +git commit -m "[RELEASE] v3.0.0 — user-level memory layer + memory CLI restructure" +``` + +(Do **not** create the git tag yet — that's the user's call after the homebrew formula is updated. The CLAUDE.md release checklist covers the tag/CI/homebrew flow.) + +--- + +### Task 21: CHANGELOG.md entry + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Prepend the v3.0.0 entry** + +At the top of `CHANGELOG.md` (under any "## Unreleased" if one exists, otherwise above the previous v2.6.2 entry), add: + +```markdown +## [3.0.0] - 2026-05-19 + +### Added +- User-level memory layer at `~/.orrery/user/memory/`. Cross-project, cross-env + personal memory served via: + - SessionStart hooks installed automatically into each env's Claude / Codex / + Gemini config (controlled by per-env `shareUserMemory`, default enabled). + - MCP tools `orrery_user_memory_read` and `orrery_user_memory_write`. + - New CLI subcommands `orrery memory user info / path / emit / export / + enable / disable`. +- Wizard question on env creation: "Enable user memory?" (default: yes). +- `--no-user-memory` flag on `orrery create` to opt out from the CLI. + +### Changed +- **BREAKING:** `orrery memory ` renamed to + `orrery memory project `. No aliases. + Scripts must be updated. +- Interactive `orrery memory` now lists both project and user memory states and + routes into the relevant submenu. + +### Internal +- Introduced `MemoryStore` value type to share read/write/fragment logic + between project- and user-level memory. +- New `UserMemoryHookInstaller` protocol with Claude / Codex / Gemini + implementations; internal `_reconcile-user-memory-hooks` command runs on + every `orrery use`. + +### Notes +- Existing memories under `~/.orrery/shared/memory/{projectKey}/` are + untouched. +- A future `orrery memory user import` will help lift cross-project entries + out of the project layer; not in this release. +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "[DOCS] CHANGELOG entry for v3.0.0" +``` + +--- + +### Task 22: orrery-sync watched paths note + +**Files:** +- Modify: `~/.orrery/sync-config.json` is **user-owned data**, do not touch. +- Modify: spec mentions docs — add a "Sync" subsection to `CHANGELOG.md` under v3.0.0 if not already there, pointing users at how to add the new path. + +This is a one-line documentation update. The actual `orrery-sync` config is owned by the user and the separate `orrery-sync` binary repo — it's out of scope here. + +- [ ] **Step 1: Append to the CHANGELOG v3.0.0 entry** + +Under `### Notes`, append: + +```markdown +- If you use `orrery-sync`, add `~/.orrery/user/memory/fragments/` to your + watched-paths list to enable cross-machine sync of user memory. The fragment + format is identical to the project layer. +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "[DOCS] note orrery-sync watched-path addition for user memory" +``` + +--- + +### Task 23: Final sanity check + +- [ ] **Step 1: Run the full test suite** + +``` +swift test +``` + +Expected: all green. + +- [ ] **Step 2: Run a manual smoke test** + +``` +swift run orrery-bin --version +# Expected: "orrery 3.0.0" + +swift run orrery-bin memory --help +# Expected: shows "project" and "user" subcommands (no flat info/export/etc.) + +swift run orrery-bin memory user --help +# Expected: shows info / path / emit / export / enable / disable + +swift run orrery-bin memory user emit +# Expected: prints nothing on a fresh install (no MEMORY.md yet); exit 0. + +mkdir -p ~/.orrery/user/memory +echo "smoke-test content" > ~/.orrery/user/memory/MEMORY.md +swift run orrery-bin memory user emit +# Expected: prints "smoke-test content" +rm ~/.orrery/user/memory/MEMORY.md +``` + +- [ ] **Step 3: Commit the smoke-test artifact** + +Nothing to commit — this is a manual check. If any output diverges from "Expected", file a follow-up issue / fix before tagging. + +--- + +## Spec Coverage Cross-check + +| Spec requirement | Task | +|---|---| +| Storage at `~/.orrery/user/memory/` | 3 | +| MemoryStore shared helper, same schema as project | 4, 5 | +| MCP `orrery_user_memory_read/write` | 10, 11 | +| `orrery memory user *` CLI (info/path/emit/export/enable/disable) | 6, 15 | +| `orrery memory project *` rename (breaking) | 7 | +| L10n updates | 8, 15, 17 | +| `shareUserMemory` on `OriginConfig` | 1 | +| `shareUserMemory` on `OrreryEnvironment` | 2 | +| `UserMemoryHookInstaller` protocol | 12 | +| Claude / Codex / Gemini installers | 12, 13 | +| `EnvironmentStore.ensureUserMemoryHooks/removeUserMemoryHooks` | 14 | +| `addTool` installs hook lazily | 19 | +| `orrery use` reconciliation via `_reconcile-user-memory-hooks` | 16 | +| Wizard question + CLI `--no-user-memory` flag | 17 | +| Origin wizard parity | 18 | +| v3.0.0 version bump | 20 | +| CHANGELOG entry | 21 | +| orrery-sync watched-path doc note | 22 | +| `emit` truncates at 25,600 bytes with hint | 5 | +| `_orrery_managed` marker; remove preserves foreign entries | 12, 13 | +| Reconcile on every `orrery use` (incl. manual hook deletion) | 16 | +| Codex uses `hooks.json` (not `config.toml`) | 13 | +| Gemini writes real `/gemini/settings.json` | 13 | + +All spec requirements have at least one task; no future-work items leaked into the plan. diff --git a/docs/superpowers/specs/2026-05-18-user-level-memory-layer-design.md b/docs/superpowers/specs/2026-05-18-user-level-memory-layer-design.md new file mode 100644 index 0000000..ee443b6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-user-level-memory-layer-design.md @@ -0,0 +1,330 @@ +# Design — User-level Memory Layer + +**Date:** 2026-05-18 +**Status:** Draft, pending user review + +## Goal + +Introduce a true **user-global** memory layer in Orrery that is independent of +any env or projectKey. It restores the cross-project "this is about *you*" layer +that Claude Code never natively provided as auto-memory, and that was further +masked when Orrery's `origin` takeover folded `~/.claude/CLAUDE.md` into a +per-env config file. Each AI tool (Claude, Codex, Gemini) loads this layer +automatically at session start via its native `SessionStart` hook. + +## Non-goals + +- Migrating existing memory files out of the project layer into the user layer + (Orrery cannot reliably classify which entries are project-specific vs. + user-global). A future `orrery memory user import` may help, but it is out of + scope for this release. +- "Rescuing" `~/.claude/CLAUDE.md` from origin takeover. After takeover, that + file is the *origin env's* CLAUDE.md, by design; the user-memory layer is + what replaces its lost cross-env semantics. +- Replacing the project-level memory layer or fragments-based sync mechanism. + The user layer is **additive**; project layer behavior is unchanged. +- Cross-machine sync transport changes. Existing `orrery-sync` machinery is + reused; only the path list it watches grows. + +## Background + +Two discoveries shaped this design: + +1. **Claude Code has no user-global auto-memory directory.** Auto-memory is + strictly per-project under `~/.claude/projects/{projectKey}/memory/`. The + only user-global mechanism is the static, hand-written `~/.claude/CLAUDE.md`. + The 4 memory types (`user`, `feedback`, `project`, `reference`) defined in + the auto-memory frontmatter are semantic labels stored per-project — so a + `type: user` memory in project A is invisible to project B. + +2. **Origin takeover converted `~/.claude/CLAUDE.md` into an env-level file.** + Symlinking `~/.claude/` into `~/.orrery/origin/claude/` is correct semantics + for *most* of the contents, but it incidentally demoted the user-global + instructions file to per-env scope. + +The user-layer this spec defines lives **outside the env hierarchy**, served +through MCP (for writes) and a `SessionStart` hook (for automatic reads). + +## Scoped decisions + +| # | Decision | +|---|---| +| 1 | User-memory storage lives at `~/.orrery/user/memory/`, outside any env. | +| 2 | Schema mirrors the project layer (`MEMORY.md` index + individual `*.md` + `fragments/`), reusing the same 4-type frontmatter taxonomy. | +| 3 | Auto-loading uses each tool's native `SessionStart` hook; the hook command (`orrery memory user emit`) prints `MEMORY.md` to stdout. | +| 4 | All three supported tools (Claude, Codex, Gemini) get hook installers in this release — not phased. | +| 5 | New MCP tools `orrery_user_memory_read` / `orrery_user_memory_write` are introduced as siblings to the existing `orrery_memory_*` pair (no `scope` parameter). | +| 6 | Per-env `shareUserMemory: Bool` defaults to `true` (opt-out, not opt-in). | +| 7 | `orrery memory` CLI is reorganised into `orrery memory project ...` and `orrery memory user ...` sub-groups; existing names become breaking changes (major version bump). | +| 8 | Existing memories are **not** auto-migrated. A `orrery memory user import` helper is deferred to future work. | +| 9 | The hook entry carries an `_orrery_managed: true` marker; `orrery use` reconciles env state to settings.json on every switch (manual hook deletion gets restored if `shareUserMemory=true`). | +| 10 | Codex hook config uses `~/.codex/hooks.json` (JSON) instead of `config.toml`, avoiding a TOML dependency. | +| 11 | `emit` truncates to 25KB and appends `(truncated, read full via orrery_user_memory_read)` so the hook stdout never exceeds the strictest tool's limit. | + +## Storage layout + +``` +~/.orrery/ +├── envs/ (existing — env-scoped) +├── shared/memory/{projectKey}/ (existing — project layer) +├── origin/ (existing — takeover storage) +└── user/ ★ new top-level + └── memory/ + ├── MEMORY.md ← index, auto-loaded by hook (first 25KB) + ├── reference_*.md ← individual entries (same schema as project) + ├── feedback_*.md + ├── user_*.md + └── fragments/ + └── f-{id}-{peer}.md ← cross-machine sync, sibling format +``` + +Path is created lazily on first write; `emit` no-ops cleanly if absent. + +`~/.orrery/user/` is a deliberate sibling of `shared/`, not a subdirectory of +it. `shared/memory/` is keyed by projectKey; `user/memory/` is not keyed at +all — its mere position above the env hierarchy is what gives it user-global +semantics. + +## MCP tools + +Both new tools are siblings of the existing `orrery_memory_*` pair, registered +by `MCPServer`: + +### `orrery_user_memory_read` + +- **Parameters:** none. +- **Returns:** contents of `~/.orrery/user/memory/MEMORY.md`. If pending + fragments exist, appends the "Pending Memory Fragments (from sync)" block + exactly the way `orrery_memory_read` already does for the project layer. +- **Description (verbatim, to be embedded in tool registration):** + *"Read the user-global Orrery memory. This memory follows you across all + projects and all environments — use it for facts about who you are (the + user), cross-project preferences, and tool/account references. Always read + before writing to avoid overwriting existing knowledge. If pending sync + fragments are present, consolidate them into MEMORY.md and write back with + `append=false`."* + +### `orrery_user_memory_write` + +- **Parameters:** `content: string`, `append: bool = true`. +- **Behavior:** writes/appends `MEMORY.md` *and* records a fragment in + `fragments/` (`action=append` or `action=overwrite`). When `append=false`, + cleans up consumed fragments after the overwrite — same semantics as the + existing project-layer `writeMemory`. +- **Description (verbatim):** + *"Write or append to the user-global Orrery memory. This persists across all + projects/envs. Use for: user role/preferences, cross-project feedback rules, + tool/account references. Default is append; set `append=false` to rewrite + (used after consolidating fragments)."* + +Both tools share an internal `MemoryStore` helper extracted from the existing +project-layer code; user/project differ only by the directory URL they hold. + +## CLI surface + +### Top-level reorganisation (breaking) + +``` +orrery memory ← interactive, shows both layer states +orrery memory project info ← was: orrery memory info +orrery memory project export ← was: orrery memory export +orrery memory project isolate ← was: orrery memory isolate +orrery memory project share ← was: orrery memory share +orrery memory project storage [PATH] ← was: orrery memory storage + +orrery memory user ← interactive, shows user-layer status +orrery memory user info +orrery memory user export [-o PATH] +orrery memory user path +orrery memory user enable ← installs hook in current env +orrery memory user disable ← removes hook in current env +orrery memory user emit ← hook target; prints MEMORY.md to stdout +``` + +`orrery memory` (no subcommand) prints a two-line status digest and a menu +that drills into either layer's submenu. No flat menu listing both layers' +actions side by side. + +### Migration & breaking-change handling + +- This is a major-version change: bump to **v3.0.0** at release. +- No aliases for the renamed commands. `CHANGELOG.md` and the upgrade notes + call out the rename. Rationale: aliases keep two name surfaces forever; a + clean break is cheaper to maintain. +- The interactive `orrery memory` keeps working without arguments, so casual + users land on the new menu naturally. + +## SessionStart hook design + +### Common command + +```sh +orrery memory user emit +``` + +`emit`: + +1. Reads `~/.orrery/user/memory/MEMORY.md`. Missing file → print nothing, + exit 0. +2. If `fragments/` is non-empty, appends the pending-fragments block (same + wording as `orrery_user_memory_read`). +3. Truncates output at 25,600 bytes. If truncated, appends: + `\n\n(truncated — read full via orrery_user_memory_read)`. +4. Writes to stdout, exits 0. Stderr is silent unless a real error occurs. + +### Per-tool hook installers + +A new protocol abstracts the per-tool config-file mechanics: + +```swift +protocol UserMemoryHookInstaller { + func install(at configDir: URL) throws + func remove(at configDir: URL) throws + func isInstalled(at configDir: URL) -> Bool +} +``` + +Three implementations, one per tool: + +#### `ClaudeHookInstaller` + +- Target file: `/settings.json`. +- Reads existing JSON, locates/creates `hooks.SessionStart`, ensures one + entry with `command == "orrery memory user emit"` and + `_orrery_managed == true`. Idempotent. +- `remove` deletes only entries with `_orrery_managed == true`. + +#### `CodexHookInstaller` + +- Target file: `/hooks.json` (sibling of `config.toml`, not its + contents). +- Same JSON-merge semantics as Claude. +- Schema/key names follow the Codex CLI hook reference at PR time; this spec + reserves the right to adjust them before merge. + +#### `GeminiHookInstaller` + +- Target file: `/gemini/settings.json` — the real file backing the + `/gemini-home/.gemini/settings.json` symlink that Gemini CLI actually + reads (Gemini CLI ignores `GEMINI_CONFIG_DIR` and resolves only via `~/.gemini/`, + so the wrapper redirects `HOME`; writing to the real file is equivalent and + avoids walking the symlink). +- Same JSON-merge semantics as Claude. + +### `EnvironmentStore` integration + +```swift +extension EnvironmentStore { + func ensureUserMemoryHooks(for envName: String) throws // calls each installer + func removeUserMemoryHooks(for envName: String) throws +} +``` + +Called from: + +- `addTool` — when a tool is added to an env that has `shareUserMemory=true`. +- `orrery use ` activation — reconciles all tools' hooks to current + `shareUserMemory` state. +- `orrery memory user enable/disable` — direct flip. +- `originTakeover` — applies the same logic against `~/.orrery/origin/`. + +## Env config schema changes + +`OrreryEnvironment` and `OriginConfig` both gain: + +```swift +public var shareUserMemory: Bool // default true +``` + +Codable: `decodeIfPresent(... ) ?? true` — existing env.json files without +this field are automatically treated as enabled. **No data migration +required.** + +## Wizard changes + +The env-creation wizard already asks about project-memory isolation. A new +question is appended *after* that block: + +``` +User memory (cross-project, cross-env personal memory layer) + + ▸ Enable (recommended) + Disable for this env + +Default: Enable. Esc to keep default. +``` + +The wizard writes `shareUserMemory` into the new env's `env.json` and the +top-level setup loop will run `ensureUserMemoryHooks` after wizard exit, so +the hook lands in every tool config that exists in the new env. + +`orrery setup` (origin path) gains the same question for `OriginConfig`. + +## Failure modes & edge cases + +| Situation | Behavior | +|---|---| +| `orrery` not on PATH when hook fires | Hook fails, AI tool warns but session continues. Documented in setup output. | +| `~/.orrery/user/memory/MEMORY.md` missing | `emit` prints nothing, exits 0. | +| `MEMORY.md` exceeds 25KB | `emit` truncates and appends a recovery hint pointing at the MCP read tool. | +| User manually edits `settings.json` to delete the hook entry | Treated as transient drift. Next `orrery use` reconciles based on `shareUserMemory`; if `true`, the entry is restored. To opt out persistently, use `orrery memory user disable`. | +| Two machines write concurrently | Fragments accumulate independently; next `orrery_user_memory_read` (or `emit`) surfaces them; AI consolidates and writes back `append=false`. Identical to project-layer semantics. | +| `shareUserMemory=true` but env has no tool installed yet | `ensureUserMemoryHooks` no-ops for that tool; later `addTool` installs the hook. | +| Existing third-party hook entries in `settings.json` | Untouched. Installer only modifies / removes entries that carry `_orrery_managed: true`. | + +## Cross-machine sync + +`orrery-sync`'s config gains one extra watched path: + +``` +~/.orrery/user/memory/fragments/ +``` + +No protocol or transport change. The fragments format +(`f-{id}-{peer}.md` with frontmatter `id`/`peer`/`timestamp`/`action`) is the +same one the project layer uses, so consolidation logic on the read side is +identical. + +## Testing approach + +- **`MemoryStore` unit tests:** read/write/append/overwrite + fragment + generation + fragment consolidation, parameterised so the same suite runs + for both project and user instances. +- **`emit` command tests:** missing file, 25KB truncation, fragments + appendix, no-fragments case. +- **`UserMemoryHookInstaller` tests, per implementation:** install on empty + config, install on config with foreign hooks present, install idempotency, + remove only-our-entries, remove with no entries. +- **`EnvironmentStore.ensureUserMemoryHooks` integration:** create env with + Claude+Codex+Gemini, toggle `shareUserMemory`, assert each tool's config + reflects the flag. +- **CLI rename:** snapshot tests for the new `orrery memory project ...` and + `orrery memory user ...` help output. +- **Wizard:** scripted run that answers default → assert new env has + `shareUserMemory=true` and hooks installed; second run with disable → assert + `false` and hooks absent. + +## Migration & rollout + +- **Version bump:** v2.6.x → **v3.0.0** (CLI rename is breaking). +- **Release notes:** dedicated section on the rename + new user-memory layer. +- **Existing data:** untouched. `~/.orrery/shared/memory/{key}/` continues to + hold project memory. Users wanting to promote entries up to the user layer + do so manually (or with the future `import` helper). +- **Existing envs:** transparent upgrade. `shareUserMemory` defaults to `true`; + the first `orrery use ` post-upgrade installs the hooks in that env's + tool configs. +- **Existing hook conflict in user's settings.json:** safe — installer + appends, doesn't replace; marker enables clean removal later. + +## Future work + +- `orrery memory user import ` — interactive helper to lift + project-layer entries that are really cross-project into the user layer. +- Cross-tool hook conflict detection (warn if a tool already has a non-Orrery + SessionStart hook calling something memory-related). +- Custom `userMemoryStoragePath` for users who want the user-memory store + pointed at an external location (Obsidian vault etc.). Parallels the + existing `memoryStoragePath` on `OrreryEnvironment`. +- A `phantom`-style ephemeral env that *doesn't* inherit user memory + (useful for demos / screen recordings). diff --git a/docs/zh_TW.html b/docs/zh_TW.html index 7d8757f..b202f11 100644 --- a/docs/zh_TW.html +++ b/docs/zh_TW.html @@ -578,7 +578,7 @@
-
v2.7.0
+
v3.0.0

解決 AI 多帳號的救星

為 Claude Code、Codex、Gemini CLI 隔離帳號,自由切換工作與個人情境 — 對話歷史無縫接續,不再從頭開始。