diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index 7527a57..6d0921b 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -173,6 +173,90 @@ public enum IDEToolRegistry { "required": .array([.string("thread_id")]), ]) ), + IDETool( + name: "ide__memory_search", + description: "Search durable RxCode memories. Use this to retrieve saved user preferences, project facts, or decisions relevant to the current task.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "query": .object([ + "type": .string("string"), + "description": .string("Natural-language search query."), + ]), + "project_id": .object([ + "type": .string("string"), + "description": .string("Optional project UUID to prefer project-local memories while still including global memories."), + ]), + "limit": .object([ + "type": .string("integer"), + "description": .string("Maximum number of memories to return. Default 20, capped at 100."), + ]), + ]), + "required": .array([.string("query")]), + ]) + ), + IDETool( + name: "ide__memory_add", + description: "Add a durable memory to RxCode. Use only for stable user preferences, project facts, or decisions that should help future work.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "content": .object(["type": .string("string")]), + "project_id": .object([ + "type": .string("string"), + "description": .string("Optional project UUID. Ignored when scope is global."), + ]), + "kind": .object([ + "type": .string("string"), + "enum": .array([.string("preference"), .string("fact"), .string("decision")]), + ]), + "scope": .object([ + "type": .string("string"), + "enum": .array([.string("project"), .string("global")]), + ]), + ]), + "required": .array([.string("content")]), + ]) + ), + IDETool( + name: "ide__memory_update", + description: "Update an existing RxCode memory by id.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "id": .object(["type": .string("string")]), + "content": .object(["type": .string("string")]), + "project_id": .object([ + "type": .string("string"), + "description": .string("Optional project UUID. Ignored when scope is global."), + ]), + "kind": .object([ + "type": .string("string"), + "enum": .array([.string("preference"), .string("fact"), .string("decision")]), + ]), + "scope": .object([ + "type": .string("string"), + "enum": .array([.string("project"), .string("global")]), + ]), + ]), + "required": .array([.string("id"), .string("content")]), + ]) + ), + IDETool( + name: "ide__memory_delete", + description: "Delete a durable RxCode memory by id.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "id": .object(["type": .string("string")]), + ]), + "required": .array([.string("id")]), + ]) + ), IDETool( name: "ide__send_to_thread", description: "Send a chat prompt to a thread in any project — continue an existing thread by `thread_id`, or start a brand-new thread by passing `project_id`. Triggers a real agent run that may consume tokens. Returns the assistant's reply text (waits up to `timeout_seconds`).", diff --git a/Packages/Sources/RxCodeCore/Models/MemoryRecord.swift b/Packages/Sources/RxCodeCore/Models/MemoryRecord.swift new file mode 100644 index 0000000..8088953 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/MemoryRecord.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftData + +public struct MemoryItem: Identifiable, Sendable, Equatable { + public let id: String + public let content: String + public let projectId: UUID? + public let sessionId: String? + public let sourceMessageId: UUID? + public let createdAt: Date + public let updatedAt: Date + public let lastUsedAt: Date? + public let kind: String + public let scope: String + + public init( + id: String, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + createdAt: Date, + updatedAt: Date, + lastUsedAt: Date?, + kind: String, + scope: String + ) { + self.id = id + self.content = content + self.projectId = projectId + self.sessionId = sessionId + self.sourceMessageId = sourceMessageId + self.createdAt = createdAt + self.updatedAt = updatedAt + self.lastUsedAt = lastUsedAt + self.kind = kind + self.scope = scope + } +} + +public struct MemoryVectorSnapshot: Sendable, Equatable { + public let item: MemoryItem + public let vector: Data + public let dim: Int + + public init(item: MemoryItem, vector: Data, dim: Int) { + self.item = item + self.vector = vector + self.dim = dim + } + + public func floatVector() -> [Float] { + let count = vector.count / MemoryLayout.size + guard count == dim else { return [] } + return vector.withUnsafeBytes { raw -> [Float] in + let buf = raw.bindMemory(to: Float.self) + return Array(buf) + } + } +} + +@Model +public final class MemoryRecord { + @Attribute(.unique) public var id: String + public var content: String + public var projectId: UUID? + public var sessionId: String? + public var sourceMessageId: UUID? + public var createdAt: Date + public var updatedAt: Date + public var lastUsedAt: Date? + public var kind: String + public var scope: String + /// L2-normalised `[Float]` packed as little-endian bytes. Length == dim * 4. + public var vector: Data + public var dim: Int + + public init( + id: String = UUID().uuidString, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + createdAt: Date = .now, + updatedAt: Date = .now, + lastUsedAt: Date? = nil, + kind: String = "fact", + scope: String = "project", + vector: Data, + dim: Int + ) { + self.id = id + self.content = content + self.projectId = projectId + self.sessionId = sessionId + self.sourceMessageId = sourceMessageId + self.createdAt = createdAt + self.updatedAt = updatedAt + self.lastUsedAt = lastUsedAt + self.kind = kind + self.scope = scope + self.vector = vector + self.dim = dim + } + + public func apply( + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String, + scope: String, + vector: Data, + dim: Int, + updatedAt: Date = .now + ) { + self.content = content + self.projectId = projectId + self.sessionId = sessionId + self.sourceMessageId = sourceMessageId + self.kind = kind + self.scope = scope + self.vector = vector + self.dim = dim + self.updatedAt = updatedAt + } + + public func touch(at date: Date = .now) { + lastUsedAt = date + } + + public func toItem() -> MemoryItem { + MemoryItem( + id: id, + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + createdAt: createdAt, + updatedAt: updatedAt, + lastUsedAt: lastUsedAt, + kind: kind, + scope: scope + ) + } + + public func toVectorSnapshot() -> MemoryVectorSnapshot { + MemoryVectorSnapshot(item: toItem(), vector: vector, dim: dim) + } +} + +extension MemoryRecord { + public func floatVector() -> [Float] { + let count = vector.count / MemoryLayout.size + guard count == dim else { return [] } + return vector.withUnsafeBytes { raw -> [Float] in + let buf = raw.bindMemory(to: Float.self) + return Array(buf) + } + } + + public static func encode(_ vector: [Float]) -> Data { + vector.withUnsafeBufferPointer { Data(buffer: $0) } + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index dc0b6d8..fef3449 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -515,6 +515,33 @@ final class AppState { var threadSummaryRevision = 0 var branchBriefingRevision = 0 + // MARK: - Memory + + var memoryEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryEnabled") as? Bool) ?? true { + didSet { UserDefaults.standard.set(memoryEnabled, forKey: "memoryEnabled") } + } + + var memoryAutoCreateEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryAutoCreateEnabled") as? Bool) ?? true { + didSet { UserDefaults.standard.set(memoryAutoCreateEnabled, forKey: "memoryAutoCreateEnabled") } + } + + var memoryInjectEnabled: Bool = (UserDefaults.standard.object(forKey: "memoryInjectEnabled") as? Bool) ?? true { + didSet { UserDefaults.standard.set(memoryInjectEnabled, forKey: "memoryInjectEnabled") } + } + + var memoryMaxContextItems: Int = (UserDefaults.standard.object(forKey: "memoryMaxContextItems") as? Int) ?? 5 { + didSet { + let clamped = max(1, min(12, memoryMaxContextItems)) + if clamped != memoryMaxContextItems { + memoryMaxContextItems = clamped + return + } + UserDefaults.standard.set(memoryMaxContextItems, forKey: "memoryMaxContextItems") + } + } + + var memoryRevision = 0 + // MARK: - Notifications var notificationsEnabled: Bool = (UserDefaults.standard.object(forKey: "notificationsEnabled") as? Bool) ?? true { @@ -945,6 +972,7 @@ final class AppState { let mcp: MCPService let threadStore: ThreadStore let searchService = ThreadSearchService() + let memoryService = MemoryService() /// Live progress for a user-triggered full reindex. `nil` when idle. var reindexProgress: (done: Int, total: Int)? = nil let runService = RunService() @@ -1067,10 +1095,12 @@ final class AppState { // loads cached chunks on `start`, then kicks off a one-time backfill // of any threads that don't have chunks yet. let searchService = self.searchService + let memoryService = self.memoryService let threadStore = self.threadStore let persistence = self.persistence Task.detached(priority: .utility) { [weak self] in await searchService.start(threadStore: threadStore) + await memoryService.start(threadStore: threadStore) await searchService.backfillIfNeeded( loadAll: { @MainActor in threadStore.loadAllSummaries() }, loadFull: { @MainActor summary -> ChatSession? in @@ -3361,6 +3391,239 @@ final class AppState { reindexProgress = nil } + // MARK: - Memory + + func allMemoryItems() async -> [MemoryItem] { + await memoryService.allMemories() + } + + func searchMemoryItems(query: String, projectId: UUID? = nil, limit: Int = 50) async -> [MemoryService.Hit] { + await memoryService.search(query, projectId: projectId, limit: limit) + } + + @discardableResult + func addMemoryItem(content: String, projectId: UUID?, kind: String = "fact", scope: String = "project") async -> MemoryItem? { + guard memoryEnabled else { return nil } + let item = await memoryService.addMemory( + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: nil, + sourceMessageId: nil, + kind: normalizedMemoryKind(kind), + scope: normalizedMemoryScope(scope) + ) + if item != nil { memoryRevision &+= 1 } + return item + } + + @discardableResult + func updateMemoryItem(id: String, content: String, projectId: UUID?, kind: String, scope: String) async -> MemoryItem? { + guard memoryEnabled else { return nil } + let item = await memoryService.updateMemory( + id: id, + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: nil, + sourceMessageId: nil, + kind: normalizedMemoryKind(kind), + scope: normalizedMemoryScope(scope) + ) + if item != nil { memoryRevision &+= 1 } + return item + } + + func deleteMemoryItem(id: String) async { + await memoryService.deleteMemory(id: id) + memoryRevision &+= 1 + } + + func deleteAllMemoryItems(projectId: UUID? = nil) async { + await memoryService.deleteAll(projectId: projectId) + memoryRevision &+= 1 + } + + private func memoryContextSystemPrompt(for hits: [MemoryService.Hit]) -> String { + guard memoryEnabled, memoryInjectEnabled, !hits.isEmpty else { return "" } + let lines = hits.prefix(memoryMaxContextItems).enumerated().map { idx, hit in + "\(idx + 1). \(hit.item.content)" + }.joined(separator: "\n") + return """ + # Relevant user memory + + The notes below are durable user/project memories saved locally in RxCode. Use them as background context for this turn. They may be incomplete or stale; the current user message still has priority. + + \(lines) + """ + } + + private func memoryContextPromptPrefix(for context: String, prompt: String) -> String { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return prompt } + return """ + \(trimmed) + + User request: + \(prompt) + """ + } + + private func scheduleMemoryExtraction( + sessionId: String, + projectId: UUID, + messages: [ChatMessage] + ) { + guard memoryEnabled, memoryAutoCreateEnabled else { return } + let userMessage = lastUserMessageText(in: messages) + let finalResponse = lastAssistantResponseText(in: messages) + guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } + let sourceMessageId = messages.last(where: { $0.role == .user && !$0.isError })?.id + let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? summaryFor(sessionId: sessionId, projectId: projectId) + + Task { [weak self] in + guard let self else { return } + await self.extractAndStoreMemories( + sessionId: sessionId, + projectId: projectId, + sourceMessageId: sourceMessageId, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) + } + } + + private func extractAndStoreMemories( + sessionId: String, + projectId: UUID, + sourceMessageId: UUID?, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async { + let relatedHits = await memoryService.search( + "\(userMessage)\n\(finalResponse)", + projectId: projectId, + limit: 6 + ) + let related = relatedHits.map { (id: $0.item.id, content: $0.item.content) } + guard let raw = await generateMemoryOperations( + existingMemories: related, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) else { return } + let operations = Self.parseMemoryOperations(raw) + guard !operations.isEmpty else { return } + + var changed = false + for operation in operations { + switch operation.action { + case "add": + guard let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty else { continue } + let scope = normalizedMemoryScope(operation.scope) + let existing = await memoryService.search(content, projectId: projectId, limit: 1) + if let best = existing.first, best.score > 0.94 { continue } + if await memoryService.addMemory( + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: normalizedMemoryKind(operation.kind), + scope: scope + ) != nil { + changed = true + } + case "update": + guard let id = operation.id, + let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty else { continue } + let scope = normalizedMemoryScope(operation.scope) + if await memoryService.updateMemory( + id: id, + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: normalizedMemoryKind(operation.kind), + scope: scope + ) != nil { + changed = true + } + case "delete": + guard let id = operation.id else { continue } + await memoryService.deleteMemory(id: id) + changed = true + default: + continue + } + } + if changed { + memoryRevision &+= 1 + } + } + + private struct MemoryOperation { + let action: String + let id: String? + let content: String? + let kind: String? + let scope: String? + } + + private static func parseMemoryOperations(_ raw: String) -> [MemoryOperation] { + let trimmed = stripJSONFence(raw) + guard let range = jsonArrayRange(in: trimmed) else { return [] } + let json = String(trimmed[range]) + guard let data = json.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return [] } + return array.compactMap { entry in + guard let action = entry["action"] as? String else { return nil } + return MemoryOperation( + action: action.lowercased(), + id: entry["id"] as? String, + content: entry["content"] as? String, + kind: entry["kind"] as? String, + scope: entry["scope"] as? String + ) + } + } + + private static func stripJSONFence(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("```") { + var lines = text.components(separatedBy: "\n") + if !lines.isEmpty { lines.removeFirst() } + if lines.last?.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { + lines.removeLast() + } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + private static func jsonArrayRange(in text: String) -> Range? { + guard let start = text.firstIndex(of: "["), + let end = text.lastIndex(of: "]"), + start <= end else { return nil } + return start.. String { + switch value?.lowercased() { + case "preference", "decision", "fact": + return value!.lowercased() + default: + return "fact" + } + } + + private func normalizedMemoryScope(_ value: String?) -> String { + value?.lowercased() == "global" ? "global" : "project" + } + // MARK: - Agent Backends /// Looks up the `AgentBackend` for the given provider. Used by @@ -4903,6 +5166,14 @@ final class AppState { } } + let resolvedMemoryContext: String + if memoryEnabled, memoryInjectEnabled { + let hits = await memoryService.search(prompt, projectId: projectId, limit: memoryMaxContextItems) + resolvedMemoryContext = memoryContextSystemPrompt(for: hits) + } else { + resolvedMemoryContext = "" + } + switch agentProvider { case .claudeCode: // Allocate a per-session IDE-MCP port so the Claude agent can call @@ -4924,14 +5195,25 @@ final class AppState { briefing: briefing.briefing ) } + appendExtraSystemPrompt(resolvedMemoryContext) if let skillContext = await marketplace.promptContext(for: .claudeCode) { appendExtraSystemPrompt(skillContext) } case .codex: - mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd) + // Allocate a per-session IDE-MCP port so the Codex agent can call + // IDE-only tools — cross-project chat, thread history, running + // jobs, usage, durable memory. The bridge is a perl one-liner + // Codex runs as the `rxcode-ide` stdio MCP server child. + let idePort = await ideMCPServer.allocate( + sessionKey: sessionKey, + capabilities: AgentProvider.codex.staticCapabilities + ) + let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } + mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge) mcpCodexOverrides += await marketplace.codexConfigOverrides() + resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) if let skillContext = await marketplace.promptContext(for: .codex) { - resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" } resolvedSendMode = registerMode case .acp: @@ -4948,8 +5230,9 @@ final class AppState { projectPath: cwd, bridgeCommand: bridge ) + resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) if let skillContext = await marketplace.promptContext(for: .acp) { - resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" } // `model` may be a composite `::` key (from the picker) // or a bare model id (from a per-session override). @@ -5395,6 +5678,11 @@ final class AppState { cwd: cwd, messages: stateForSession(sessionKey).messages ) + scheduleMemoryExtraction( + sessionId: resultEvent.sessionId, + projectId: projectId, + messages: stateForSession(sessionKey).messages + ) // If this session is running in the background, automatically process any queued messages. // Foreground sessions are handled by InputBarView via isStreaming onChange. @@ -6760,6 +7048,42 @@ final class AppState { } } + private func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + } + } + /// Generates a commit message for the staged changes in the given project. /// Routes through the configured `summarizationProvider`. Returns nil on /// failure or when no provider is configured. Public so the Changes view @@ -6989,6 +7313,33 @@ final class AppState { } } + private func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + model: model + ) + case .acp: + return nil + } + } + private func generateResponseNotificationSummary(responseText: String, provider: AgentProvider, model: String?) async -> String? { switch provider { case .claudeCode: @@ -7334,6 +7685,7 @@ final class AppState { threadStore.deleteAll(projectId: project.id) let projectId = project.id Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } + Task.detached(priority: .utility) { [memoryService] in await memoryService.deleteAll(projectId: projectId) } allSessionSummaries.removeAll { $0.projectId == project.id } // Remove from projects list and persist diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 39caaba..c6c2aca 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -369,6 +369,20 @@ actor ClaudeCodeServer { return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) } + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let prompt = OpenAISummarizationService.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + return await generatePlainSummary(prompt: prompt, model: model, limit: 3000) + } + func generateBranchBriefing( threadSummaries: [(title: String, summary: String)], model: String = "claude-haiku-4-5-20251001" @@ -760,7 +774,8 @@ actor ClaudeCodeServer { /// Extra system-prompt text appended via `--append-system-prompt`. Tells the /// agent about the IDE-provided `rxcode-ide` MCP server, which lets it talk - /// to agents in other RxCode projects/threads and introspect editor state. + /// to agents in other RxCode projects/threads, introspect editor state, and + /// recall/store durable cross-session memories. private static let ideToolsSystemPrompt = """ # RxCode IDE tools @@ -789,6 +804,25 @@ actor ClaudeCodeServer { project already did, or delegating a subtask to its agent — rather than \ guessing. Prefer reading threads first; only use `ide__send_to_thread` when \ you actually need another agent to act, since it costs tokens. + + RxCode also persists durable memories — stable user preferences, project \ + facts, and decisions — across sessions. Use these tools to recall and \ + store them: + + - `mcp__rxcode-ide__ide__memory_search` — before starting a task, search \ + for saved preferences, facts, or decisions relevant to it instead of \ + asking the user to repeat themselves. + - `mcp__rxcode-ide__ide__memory_add` — when the user states a stable \ + preference, project fact, or decision ("remember…", "from now on…", \ + "always…"), save it. Set `kind` (`preference`/`fact`/`decision`) and \ + `scope` (`global` for cross-project, `project` for repo-specific). + - `mcp__rxcode-ide__ide__memory_update` — when saved information changes, \ + update the existing entry by `id` rather than adding a duplicate. + - `mcp__rxcode-ide__ide__memory_delete` — remove a memory by `id` when it \ + is no longer valid. + + Only store stable, reusable information in memory — not transient task \ + details. """ /// Build arguments array for the CLI invocation. diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift index e2c2e4c..3dbb6ae 100644 --- a/RxCode/Services/CodexAppServer.swift +++ b/RxCode/Services/CodexAppServer.swift @@ -313,6 +313,21 @@ actor CodexAppServer { return cleanSummary(raw, limit: 1800) } + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + model: String? + ) async -> String? { + let prompt = OpenAISummarizationService.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 3000) + } + func generateBranchBriefing( threadSummaries: [(title: String, summary: String)], model: String? @@ -512,6 +527,9 @@ actor CodexAppServer { let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd, configOverrides: mcpConfigOverrides) try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + // Surface the IDE tools blurb as developer instructions only when + // the `rxcode-ide` MCP bridge is wired into this turn's overrides. + let ideInstructions = Self.includesIDEServer(mcpConfigOverrides) ? Self.ideToolsDeveloperInstructions : nil var activeThreadId = threadId var turnStarted = false var turnCompleted = false @@ -534,7 +552,7 @@ actor CodexAppServer { try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) let method = activeThreadId == nil ? "thread/start" : "thread/resume" let params = method == "thread/start" - ? threadStartParams(threadId: activeThreadId, cwd: cwd, permissionMode: permissionMode, planMode: planMode) + ? threadStartParams(threadId: activeThreadId, cwd: cwd, permissionMode: permissionMode, planMode: planMode, ideInstructions: ideInstructions) : threadParams(threadId: activeThreadId, cwd: cwd) try Self.writeJSONLine(Self.request(id: 2, method: method, params: params), to: handles.stdin) case "2": @@ -832,13 +850,16 @@ actor CodexAppServer { return params } - private func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { + private func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool, ideInstructions: String?) -> [String: JSONValue] { var params: [String: JSONValue] = ["cwd": .string(cwd)] if let threadId { params["threadId"] = .string(threadId) } params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) params["sandbox"] = .string(Self.codexSandboxMode(permissionMode: permissionMode, planMode: planMode)) - if planMode { - params["developerInstructions"] = .string(Self.planModeInstructions) + var instructions: [String] = [] + if let ideInstructions, !ideInstructions.isEmpty { instructions.append(ideInstructions) } + if planMode { instructions.append(Self.planModeInstructions) } + if !instructions.isEmpty { + params["developerInstructions"] = .string(instructions.joined(separator: "\n\n")) } return params } @@ -864,6 +885,59 @@ actor CodexAppServer { wait for the user to disable plan mode before making changes. """ + /// Developer-role instructions handed to Codex on `thread/start`. Tells the + /// agent about the IDE-provided `rxcode-ide` MCP server — cross-project + /// chat, thread history, running jobs, usage, and durable cross-session + /// memory. Only emitted when the IDE MCP bridge is wired into the turn. + private static let ideToolsDeveloperInstructions = """ + # RxCode IDE tools + + You are running inside RxCode, a desktop IDE that hosts multiple projects, \ + each with its own chat threads and agents. RxCode wires in a local MCP \ + server named `rxcode-ide`; its tools appear in your available tools list. \ + Use them to coordinate with other agents and to read editor state: + + - `ide__get_projects` — list every project registered in RxCode, so you \ + can discover sibling projects to read or message. + - `ide__get_threads` — list or natural-language search chat threads across \ + projects. + - `ide__get_thread_messages` — fetch the message history of a specific \ + thread by id. + - `ide__send_to_thread` — talk to another project's agent: send a prompt \ + to an existing thread or start a new thread in a project. This triggers a \ + real agent run that may consume tokens; it returns the other agent's reply. + - `ide__get_running_jobs` / `ide__get_job_output` — inspect run-profile \ + jobs executing in the IDE. + - `ide__get_usage` — current rate-limit / token usage. + + Reach for these when a task spans projects rather than guessing. Prefer \ + reading threads first; only use `ide__send_to_thread` when you actually \ + need another agent to act, since it costs tokens. + + RxCode also persists durable memories — stable user preferences, project \ + facts, and decisions — across sessions. Use these tools to recall and \ + store them: + + - `ide__memory_search` — before starting a task, search for saved \ + preferences, facts, or decisions relevant to it instead of asking the \ + user to repeat themselves. + - `ide__memory_add` — when the user states a stable preference, project \ + fact, or decision ("remember…", "from now on…", "always…"), save it. Set \ + `kind` (`preference`/`fact`/`decision`) and `scope` (`global` for \ + cross-project, `project` for repo-specific). + - `ide__memory_update` — when saved information changes, update the \ + existing entry by `id` rather than adding a duplicate. + - `ide__memory_delete` — remove a memory by `id` when it is no longer valid. + + Only store stable, reusable information in memory — not transient task \ + details. + """ + + /// True when the `rxcode-ide` MCP bridge is present in the `-c` overrides. + private static func includesIDEServer(_ overrides: [String]) -> Bool { + overrides.contains { $0.hasPrefix("mcp_servers.rxcode-ide=") } + } + private static func codexApprovalPolicy(permissionMode: PermissionMode, planMode: Bool) -> String { if planMode { return "on-request" } switch permissionMode { diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index 7a5c383..c103c3f 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -92,6 +92,23 @@ actor FoundationModelSummarizationService { return cleanSummary(raw, limit: 1800) } + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String + ) async -> String? { + let prompt = OpenAISummarizationService.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + let raw = await respond( + instructions: "You extract concise durable memory as JSON operations. Output only JSON.", + prompt: prompt + ) + return cleanSummary(raw, limit: 3000) + } + func generateBranchBriefing( threadSummaries: [(title: String, summary: String)] ) async -> String? { diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index 9cfa18a..abba47c 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -42,6 +42,14 @@ extension AppState: IDEToolHandling { return await handleGetThreads(arguments: arguments) case "ide__get_thread_messages", "ide__get_thread_detail": return try await handleGetThreadMessages(arguments: arguments) + case "ide__memory_search": + return try await handleMemorySearch(arguments: arguments) + case "ide__memory_add": + return try await handleMemoryAdd(arguments: arguments) + case "ide__memory_update": + return try await handleMemoryUpdate(arguments: arguments) + case "ide__memory_delete": + return try await handleMemoryDelete(arguments: arguments) case "ide__send_to_thread": return try await handleSendToThread(arguments: arguments) case "ide__get_usage": @@ -231,6 +239,67 @@ extension AppState: IDEToolHandling { ])) } + @MainActor + private func handleMemorySearch(arguments: JSONValue) async throws -> JSONValue { + guard let query = arguments["query"]?.stringValue, !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw IDEToolError.invalidArguments("missing 'query'") + } + let projectId = try parseOptionalProjectId(arguments["project_id"]?.stringValue) + let requestedLimit = Int(arguments["limit"]?.numberValue ?? 20) + let limit = max(1, min(requestedLimit, 100)) + let hits = await searchMemoryItems(query: query, projectId: projectId, limit: limit) + return jsonTextResult(.array(hits.map { memoryJSON(item: $0.item, score: $0.score) })) + } + + @MainActor + private func handleMemoryAdd(arguments: JSONValue) async throws -> JSONValue { + guard let content = arguments["content"]?.stringValue, !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw IDEToolError.invalidArguments("missing 'content'") + } + let scope = arguments["scope"]?.stringValue ?? "project" + let projectId = try parseOptionalProjectId(arguments["project_id"]?.stringValue) + guard let item = await addMemoryItem( + content: content, + projectId: projectId, + kind: arguments["kind"]?.stringValue ?? "fact", + scope: scope + ) else { + throw IDEToolError.handlerFailed("Memory could not be embedded or stored.") + } + return jsonTextResult(memoryJSON(item: item, score: nil)) + } + + @MainActor + private func handleMemoryUpdate(arguments: JSONValue) async throws -> JSONValue { + guard let id = arguments["id"]?.stringValue, !id.isEmpty else { + throw IDEToolError.invalidArguments("missing 'id'") + } + guard let content = arguments["content"]?.stringValue, !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw IDEToolError.invalidArguments("missing 'content'") + } + let scope = arguments["scope"]?.stringValue ?? "project" + let projectId = try parseOptionalProjectId(arguments["project_id"]?.stringValue) + guard let item = await updateMemoryItem( + id: id, + content: content, + projectId: projectId, + kind: arguments["kind"]?.stringValue ?? "fact", + scope: scope + ) else { + throw IDEToolError.handlerFailed("Memory \(id) could not be updated.") + } + return jsonTextResult(memoryJSON(item: item, score: nil)) + } + + @MainActor + private func handleMemoryDelete(arguments: JSONValue) async throws -> JSONValue { + guard let id = arguments["id"]?.stringValue, !id.isEmpty else { + throw IDEToolError.invalidArguments("missing 'id'") + } + await deleteMemoryItem(id: id) + return textResult("Deleted memory \(id).") + } + @MainActor private func handleSendToThread(arguments: JSONValue) async throws -> JSONValue { guard let prompt = arguments["prompt"]?.stringValue, !prompt.isEmpty else { @@ -286,6 +355,37 @@ extension AppState: IDEToolHandling { } } + private func parseOptionalProjectId(_ raw: String?) throws -> UUID? { + guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + guard let id = UUID(uuidString: raw) else { + throw IDEToolError.invalidArguments("'project_id' is not a valid UUID: \(raw)") + } + return id + } + + private func memoryJSON(item: MemoryItem, score: Float?) -> JSONValue { + var obj: [String: JSONValue] = [ + "id": .string(item.id), + "content": .string(item.content), + "kind": .string(item.kind), + "scope": .string(item.scope), + "created_at": .string(ISO8601DateFormatter().string(from: item.createdAt)), + "updated_at": .string(ISO8601DateFormatter().string(from: item.updatedAt)), + "project_id": item.projectId.map { .string($0.uuidString) } ?? .null, + "session_id": item.sessionId.map { .string($0) } ?? .null, + ] + if let sourceMessageId = item.sourceMessageId { + obj["source_message_id"] = .string(sourceMessageId.uuidString) + } + if let lastUsedAt = item.lastUsedAt { + obj["last_used_at"] = .string(ISO8601DateFormatter().string(from: lastUsedAt)) + } + if let score { + obj["score"] = .number(Double(score)) + } + return .object(obj) + } + private func handleGetUsage() async -> JSONValue { let provider = await MainActor.run { selectedAgentProvider } let usage = await rateLimitUsage(for: provider, forceRefresh: false) diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index cfe98f4..5e95ac1 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -256,10 +256,25 @@ actor MCPService { } } - func codexConfigOverrides(projectPath: String?) async -> [String] { + /// Build the `-c` overrides handed to the Codex app-server child. If + /// `bridgeCommand` is non-nil an extra `rxcode-ide` stdio MCP server is + /// emitted — the local MCP polyfill / introspection server that exposes + /// IDE-only tools (cross-project chat, thread history, running jobs, + /// usage, durable memory). + func codexConfigOverrides( + projectPath: String?, + bridgeCommand: (command: String, args: [String])? = nil + ) async -> [String] { do { let config = try loadConfig() var pairs: [String] = [] + if let bridge = bridgeCommand { + // Emit the full inline table — a partial override fails codex's + // validation when the server isn't already defined in + // ~/.codex/config.toml. + let table = "{enabled=true,command=\(tomlString(bridge.command)),args=\(tomlArray(bridge.args))}" + pairs += ["-c", "mcp_servers.rxcode-ide=\(table)"] + } for record in config.servers.sorted(by: { $0.name < $1.name }) { // Always emit the full inline table. A bare `enabled=false` // override fails codex's validation ("invalid transport") when diff --git a/RxCode/Services/MemoryService.swift b/RxCode/Services/MemoryService.swift new file mode 100644 index 0000000..25965c9 --- /dev/null +++ b/RxCode/Services/MemoryService.swift @@ -0,0 +1,208 @@ +import Foundation +import NaturalLanguage +import RxCodeCore +import os + +actor MemoryService { + struct Hit: Sendable, Equatable, Identifiable { + let item: MemoryItem + let score: Float + var id: String { item.id } + } + + private struct Entry { + var item: MemoryItem + var vector: [Float] + } + + private let logger = Logger(subsystem: "com.claudework", category: "MemoryService") + private var entries: [String: Entry] = [:] + private var embedder: NLEmbedding? + private var wordEmbedder: NLEmbedding? + private var threadStore: ThreadStore? + private var didStart = false + + func start(threadStore: ThreadStore) async { + guard !didStart else { return } + didStart = true + self.threadStore = threadStore + embedder = NLEmbedding.sentenceEmbedding(for: .english) + wordEmbedder = NLEmbedding.wordEmbedding(for: .english) + logger.info("start(): sentenceEmbedder=\(self.embedder != nil), wordEmbedder=\(self.wordEmbedder != nil)") + + let rows = await MainActor.run { threadStore.loadAllMemorySnapshots() } + for row in rows { + let vector = row.floatVector() + guard !vector.isEmpty else { continue } + entries[row.item.id] = Entry(item: row.item, vector: vector) + } + logger.info("Loaded \(rows.count) memory rows, indexed=\(self.entries.count)") + } + + func allMemories() async -> [MemoryItem] { + entries.values + .map(\.item) + .sorted { $0.updatedAt > $1.updatedAt } + } + + func search(_ query: String, projectId: UUID?, limit: Int = 20) async -> [Hit] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let qvec = embed(trimmed) else { return [] } + + let ranked = entries.values.compactMap { entry -> Hit? in + if let memoryProject = entry.item.projectId, + let projectId, + memoryProject != projectId { + return nil + } + let score = dot(qvec, entry.vector) + return Hit(item: entry.item, score: score) + } + .sorted { $0.score > $1.score } + .prefix(max(1, limit)) + + let hits = Array(ranked) + if let store = threadStore { + let ids = hits.map(\.item.id) + await MainActor.run { store.touchMemories(ids: ids) } + } + return hits + } + + @discardableResult + func addMemory( + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String = "fact", + scope: String = "project" + ) async -> MemoryItem? { + await upsertMemory( + id: nil, + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: kind, + scope: scope + ) + } + + @discardableResult + func updateMemory( + id: String, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String, + scope: String + ) async -> MemoryItem? { + await upsertMemory( + id: id, + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: kind, + scope: scope + ) + } + + func deleteMemory(id: String) async { + entries.removeValue(forKey: id) + guard let store = threadStore else { return } + await MainActor.run { store.deleteMemory(id: id) } + } + + func deleteAll(projectId: UUID? = nil) async { + if let projectId { + entries = entries.filter { $0.value.item.projectId != projectId } + } else { + entries.removeAll() + } + guard let store = threadStore else { return } + await MainActor.run { store.deleteAllMemories(projectId: projectId) } + } + + @discardableResult + private func upsertMemory( + id: String?, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String, + scope: String + ) async -> MemoryItem? { + guard let store = threadStore else { return nil } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let vector = embed(trimmed) else { return nil } + let encoded = MemoryRecord.encode(vector) + let item = await MainActor.run { + store.upsertMemory( + id: id, + content: trimmed, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: kind, + scope: scope, + vector: encoded, + dim: vector.count + ) + } + entries[item.id] = Entry(item: item, vector: vector) + return item + } + + private func embed(_ text: String) -> [Float]? { + if let embedder, let vec = embedder.vector(for: text) { + return normalise(vec.map { Float($0) }) + } + guard let wordEmbedder else { return nil } + let tokens = tokenize(text) + var sum: [Float] = Array(repeating: 0, count: wordEmbedder.dimension) + var count = 0 + for token in tokens { + guard let v = wordEmbedder.vector(for: token) else { continue } + for (i, x) in v.enumerated() where i < sum.count { + sum[i] += Float(x) + } + count += 1 + } + guard count > 0 else { return nil } + let inv = 1.0 / Float(count) + for i in 0.. [String] { + let lower = text.lowercased() + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = lower + var tokens: [String] = [] + tokenizer.enumerateTokens(in: lower.startIndex.. [Float] { + var sum: Float = 0 + for x in vec { sum += x * x } + let n = sum.squareRoot() + guard n > 0 else { return vec } + let inv = 1.0 / n + return vec.map { $0 * inv } + } + + private func dot(_ a: [Float], _ b: [Float]) -> Float { + let count = min(a.count, b.count) + var s: Float = 0 + for i in 0.. [String: Any] { @@ -859,15 +884,25 @@ final class MobileSyncService: ObservableObject { logger.info("[LiveActivity] device started aggregate activity locally mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) — deferred push-to-start cancelled") } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, let activityID = t.activityID { - // One aggregate activity per device — replace any prior token. - pairedDevices[idx].liveActivityTokens = [ - LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) - ] - logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") cancelJobsActivityStart() - // Push the latest known state straight away so a freshly - // started activity isn't left blank until the next change. - if !trackedJobs.isEmpty { + if trackedJobs.isEmpty { + // This (fresh) desktop process tracks no jobs, yet the + // device still has a job activity registered — it is an + // orphan left behind by a previous desktop session that + // crashed or quit mid-job and never delivered the + // finishing update. End it so it doesn't sit stuck on + // "Working" forever, and drop the now-dead token. + logger.info("[LiveActivity] activity token from device with no tracked jobs — ending orphaned activity activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + sendJobsActivityEnd(deviceToken: activityToken, device: pairedDevices[idx]) + pairedDevices[idx].liveActivityTokens = nil + } else { + // One aggregate activity per device — replace any prior token. + pairedDevices[idx].liveActivityTokens = [ + LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) + ] + logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + // Push the latest known state straight away so a freshly + // started activity isn't left blank until the next change. lastPushedJobsSignature = jobsSignature sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) } diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 1ac296d..b185000 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -153,6 +153,22 @@ actor OpenAISummarizationService { return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 256) } + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + endpoint: String, + apiKey: String, + model: String + ) async -> String? { + let prompt = Self.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 512) + } + func generateBranchBriefing( threadSummaries: [(title: String, summary: String)], endpoint: String, @@ -249,6 +265,42 @@ actor OpenAISummarizationService { """ } + static func memoryExtractionPrompt( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String + ) -> String { + let existing = existingMemories.isEmpty + ? "None" + : existingMemories.map { "- id: \($0.id)\n content: \($0.content)" }.joined(separator: "\n") + + return """ + Decide whether the latest chat turn contains durable user memory for a local coding IDE. + + Return [] unless the turn contains information that is likely to be useful in future, separate agent runs. + Store only stable preferences, recurring workflow instructions, project-specific decisions, naming conventions, or durable facts that would help future agent runs. + Prefer facts stated by the user. Use the assistant response only to capture confirmed outcomes or project decisions. + Do not save routine requests, one-off tasks, bug reports, temporary debugging details, implementation steps, command requests, secrets, API keys, credentials, or vague observations. + Do not add memory simply because the user sent a message. Most user messages should produce []. + Add at most 1-3 memories, and only when each memory is clearly reusable beyond the current conversation. + If an existing memory should be refined, return an update operation with its id. If a memory is no longer valid because the user corrected it, return delete. + + Reply with ONLY a JSON array. No markdown. Each entry must be one of: + {"action":"add","content":"memory text","kind":"preference|fact|decision","scope":"project|global"} + {"action":"update","id":"existing-id","content":"new memory text","kind":"preference|fact|decision","scope":"project|global"} + {"action":"delete","id":"existing-id"} + + Existing related memories: + \(existing) + + Latest user message: + \(String(userMessage.prefix(3000))) + + Final assistant response: + \(String(finalResponse.prefix(3000))) + """ + } + private func generateSummary(prompt: String, endpoint: String, apiKey: String, model: String, maxTokens: Double) async -> String? { let body: JSONValue = .object([ "model": .string(model), diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index e1e3f6c..de14418 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -25,7 +25,8 @@ final class ThreadStore { PlanDecisionRecord.self, ThreadSummaryRecord.self, BranchBriefingRecord.self, - ThreadEmbeddingChunk.self + ThreadEmbeddingChunk.self, + MemoryRecord.self ]) let url = Self.storeURL() let config = ModelConfiguration(schema: schema, url: url) @@ -584,6 +585,104 @@ final class ThreadStore { for row in rows { context.delete(row) } } + // MARK: - Memories + + func loadAllMemories() -> [MemoryRecord] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + func loadAllMemorySnapshots() -> [MemoryVectorSnapshot] { + loadAllMemories().map { $0.toVectorSnapshot() } + } + + func fetchMemory(id: String) -> MemoryRecord? { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + + func upsertMemory( + id: String?, + content: String, + projectId: UUID?, + sessionId: String?, + sourceMessageId: UUID?, + kind: String, + scope: String, + vector: Data, + dim: Int + ) -> MemoryItem { + let memoryId = id ?? UUID().uuidString + let now = Date() + if let existing = fetchMemory(id: memoryId) { + existing.apply( + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: kind, + scope: scope, + vector: vector, + dim: dim, + updatedAt: now + ) + save() + return existing.toItem() + } else { + let row = MemoryRecord( + id: memoryId, + content: content, + projectId: projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + createdAt: now, + updatedAt: now, + kind: kind, + scope: scope, + vector: vector, + dim: dim + ) + context.insert(row) + save() + return row.toItem() + } + } + + func touchMemories(ids: [String], at date: Date = .now) { + guard !ids.isEmpty else { return } + for id in ids { + fetchMemory(id: id)?.touch(at: date) + } + save() + } + + func deleteMemory(id: String) { + guard let row = fetchMemory(id: id) else { return } + context.delete(row) + save() + } + + func deleteAllMemories(projectId: UUID? = nil) { + if let projectId { + deleteMemoryRows(projectId: projectId) + } else { + let rows = (try? context.fetch(FetchDescriptor())) ?? [] + for row in rows { context.delete(row) } + } + save() + } + + private func deleteMemoryRows(projectId: UUID) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.projectId == projectId } + ) + let rows = (try? context.fetch(descriptor)) ?? [] + for row in rows { context.delete(row) } + } + // MARK: - Thread Embedding Chunks func loadAllEmbeddingChunks() -> [ThreadEmbeddingChunk] { diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 977f677..f183116 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -1,6 +1,6 @@ -import SwiftUI -import RxCodeCore import RxCodeChatKit +import RxCodeCore +import SwiftUI import TipKit // MARK: - Settings Sheet @@ -18,10 +18,10 @@ struct SettingsView: View { showUserManual: $showUserManual, showOnboarding: $showOnboarding ) - .tabItem { - Label("General", systemImage: "slider.horizontal.3") - } - .tag(0) + .tabItem { + Label("General", systemImage: "slider.horizontal.3") + } + .tag(0) ChatSettingsTab() .tabItem { @@ -108,6 +108,8 @@ struct GeneralSettingsTab: View { Divider() searchIndexSection Divider() + MemorySettingsSection() + Divider() VStack(alignment: .leading, spacing: 8) { onboardingSection helpSection @@ -624,12 +626,12 @@ struct ChatSettingsTab: View { appState.availableAgentModelSections() .flatMap(\.models) .first { $0.provider == appState.selectedAgentProvider && $0.id == appState.selectedModel } - ?? AgentModel( - provider: appState.selectedAgentProvider, - id: appState.selectedModel, - displayName: appState.modelDisplayLabel(appState.selectedModel, provider: appState.selectedAgentProvider), - description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) - ) + ?? AgentModel( + provider: appState.selectedAgentProvider, + id: appState.selectedModel, + displayName: appState.modelDisplayLabel(appState.selectedModel, provider: appState.selectedAgentProvider), + description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) + ) } // MARK: - Summarization Section @@ -706,7 +708,8 @@ struct ChatSettingsTab: View { HStack(spacing: 10) { Picker("Model", selection: $appState.openAISummarizationModel) { if !appState.openAISummarizationModel.isEmpty, - !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) { + !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) + { Text(appState.openAISummarizationModel).tag(appState.openAISummarizationModel) } ForEach(appState.openAISummarizationModels, id: \.self) { model in @@ -856,13 +859,413 @@ struct ChatSettingsTab: View { private func effortDisplayName(_ effort: String) -> String { switch effort { - case "low": return "Low" + case "low": return "Low" case "medium": return "Medium" - case "high": return "High" - case "xhigh": return "Extra High" - case "max": return "Max" - default: return effort.capitalized + case "high": return "High" + case "xhigh": return "Extra High" + case "max": return "Max" + default: return effort.capitalized + } + } +} + +// MARK: - Memory Settings Section + +struct MemorySettingsSection: View { + @Environment(AppState.self) private var appState + + @State private var editingDraft: MemoryDraft? + @State private var showMemoryBrowser = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + controlsSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(item: $editingDraft) { draft in + MemoryEditorSheet(draft: draft) + } + .sheet(isPresented: $showMemoryBrowser) { + MemoryBrowserSheet() + .environment(appState) + } + } + + private var controlsSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Memory") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Spacer() + Button { + editingDraft = MemoryDraft() + } label: { + Label("Add", systemImage: "plus") + } + .help("Add memory") + Button { + showMemoryBrowser = true + } label: { + Label("Manage", systemImage: "list.bullet.rectangle") + } + .help("View and manage saved memories") + } + + Toggle(isOn: $appState.memoryEnabled) { + Text("Enable Memory") + } + .toggleStyle(.switch) + .fixedSize() + + Toggle(isOn: $appState.memoryAutoCreateEnabled) { + Text("Auto-create memories from completed chats") + } + .toggleStyle(.switch) + .fixedSize() + .disabled(!appState.memoryEnabled) + + Toggle(isOn: $appState.memoryInjectEnabled) { + Text("Include relevant memories in agent context") + } + .toggleStyle(.switch) + .fixedSize() + .disabled(!appState.memoryEnabled) + + Stepper(value: $appState.memoryMaxContextItems, in: 1...12) { + Text("Context memories: \(appState.memoryMaxContextItems)") + .font(.system(size: ClaudeTheme.size(12))) + } + .fixedSize() + .disabled(!appState.memoryEnabled || !appState.memoryInjectEnabled) + + Text("Saved memory history is available from Manage.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } +} + +private struct MemoryBrowserSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var query = "" + @State private var items: [MemoryItem] = [] + @State private var isLoading = false + @State private var editingDraft: MemoryDraft? + @State private var deleteTarget: MemoryItem? + @State private var showDeleteAllConfirmation = false + + var body: some View { + @Bindable var appState = appState + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Memory History") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + Spacer() + Button { + editingDraft = MemoryDraft() + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + + Button(role: .destructive) { + showDeleteAllConfirmation = true + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Delete all memories") + .disabled(items.isEmpty) + Button("Done") { dismiss() } + } + + searchSection + + ScrollView { + memoryList + } + } + .padding(20) + .frame(width: 640, height: 540) + .task { await refresh() } + .onChange(of: appState.memoryRevision) { _, _ in + Task { await refresh() } + } + .sheet(item: $editingDraft) { draft in + MemoryEditorSheet(draft: draft) { + Task { await refresh() } + } + } + .alert("Delete Memory?", isPresented: Binding( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + )) { + Button("Delete", role: .destructive) { + if let target = deleteTarget { + Task { + await appState.deleteMemoryItem(id: target.id) + deleteTarget = nil + await refresh() + } + } + } + Button("Cancel", role: .cancel) { deleteTarget = nil } + } message: { + Text("This memory will be removed from future agent context.") + } + .alert("Delete All Memories?", isPresented: $showDeleteAllConfirmation) { + Button("Delete All", role: .destructive) { + Task { + await appState.deleteAllMemoryItems() + await refresh() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("All saved memories will be removed. This action cannot be undone.") + } + } + + private var searchSection: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search memory", text: $query) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await refresh() } } + Button { + Task { await refresh() } + } label: { + if isLoading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.borderless) + .help("Refresh") + .disabled(isLoading) + } + } + + @ViewBuilder + private var memoryList: some View { + if items.isEmpty && !isLoading { + VStack(alignment: .leading, spacing: 6) { + Text(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No memories" : "No matching memories") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Saved memories are stored locally in SwiftData and embedded on-device.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 18) + } else { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(items) { item in + memoryRow(item) + } + } + } + } + + private func memoryRow(_ item: MemoryItem) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.scope == "global" ? "globe" : "folder") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 5) { + Text(item.content) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Text(item.kind.capitalized) + Text(item.scope.capitalized) + if let projectId = item.projectId { + Text(projectName(for: projectId)) + } + Text(item.updatedAt, style: .date) + } + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Button { + editingDraft = MemoryDraft(item: item) + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Edit memory") + + Button(role: .destructive) { + deleteTarget = item + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Delete memory") + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func refresh() async { + isLoading = true + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + items = await appState.allMemoryItems() + } else { + items = await appState.searchMemoryItems(query: trimmed, limit: 100).map(\.item) + } + isLoading = false + } + + private func projectName(for id: UUID) -> String { + appState.projects.first(where: { $0.id == id })?.name ?? id.uuidString + } +} + +private struct MemoryDraft: Identifiable { + let id = UUID() + var existingId: String? + var content: String + var kind: String + var scope: String + var projectId: UUID? + + init() { + self.existingId = nil + self.content = "" + self.kind = "fact" + self.scope = "project" + self.projectId = nil + } + + init(item: MemoryItem) { + self.existingId = item.id + self.content = item.content + self.kind = item.kind + self.scope = item.scope + self.projectId = item.projectId + } +} + +private struct MemoryEditorSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var draft: MemoryDraft + /// Invoked after a successful save so a presenting list can refresh. + /// Deliberately argument-free: the draft is persisted here, inside the + /// sheet, so the struct never crosses an escaping async closure boundary. + private let onSaved: (() -> Void)? + + init(draft: MemoryDraft, onSaved: (() -> Void)? = nil) { + _draft = State(initialValue: draft) + self.onSaved = onSaved + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(draft.existingId == nil ? "Add Memory" : "Edit Memory") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + + TextEditor(text: $draft.content) + .font(.system(size: ClaudeTheme.size(12))) + .frame(height: 110) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + + HStack(spacing: 12) { + Picker("Kind", selection: $draft.kind) { + Text("Fact").tag("fact") + Text("Preference").tag("preference") + Text("Decision").tag("decision") + } + .pickerStyle(.menu) + .frame(width: 180) + + Picker("Scope", selection: $draft.scope) { + Text("Project").tag("project") + Text("Global").tag("global") + } + .pickerStyle(.menu) + .frame(width: 180) + } + + if draft.scope == "project" { + Picker("Project", selection: projectSelection) { + Text("No project").tag("") + ForEach(appState.projects) { project in + Text(project.name).tag(project.id.uuidString) + } + } + .pickerStyle(.menu) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button("Save") { + Task { + await performSave() + dismiss() + } + } + .keyboardShortcut(.defaultAction) + .disabled(draft.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 460) + } + + /// Persists the draft directly through AppState. Kept inside the sheet — + /// rather than handed back via a `(MemoryDraft) async -> Void` callback — + /// so the struct is never passed through an escaping async closure. + private func performSave() async { + let draft = draft + if let id = draft.existingId { + _ = await appState.updateMemoryItem( + id: id, + content: draft.content, + projectId: draft.projectId, + kind: draft.kind, + scope: draft.scope + ) + } else { + _ = await appState.addMemoryItem( + content: draft.content, + projectId: draft.projectId, + kind: draft.kind, + scope: draft.scope + ) } + onSaved?() + } + + private var projectSelection: Binding { + Binding( + get: { draft.projectId?.uuidString ?? "" }, + set: { raw in draft.projectId = UUID(uuidString: raw) } + ) } } diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift index 9e1d4fc..feb8da7 100644 --- a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -75,8 +75,19 @@ final class MobileLiveActivityCoordinator { .receive(on: DispatchQueue.main) .sink { [weak self] sessions in self?.startActivityIfNeeded(for: sessions) + if #available(iOS 16.2, *) { + self?.reconcileActivities(for: sessions) + } } .store(in: &cancellables) + // The desktop drives the activity and never ends it, so a lost + // finishing push can leave it stuck on "Working". Re-check whenever + // the app is foregrounded — the moment the user is most likely to be + // looking at a stalled activity and expecting it gone. + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.reconcileActivities() } + .store(in: &cancellables) } private func startObserving() { @@ -270,6 +281,86 @@ final class MobileLiveActivityCoordinator { ) } + // MARK: - Stalled-activity reconciliation + + /// Longest a job activity may go without a desktop update before, with the + /// relay also down, it is treated as dead and ended. A live job refreshes + /// the activity far more often; a window this long only ever catches an + /// activity whose desktop crashed or quit mid-job. + private let staleActivityEndThreshold: TimeInterval = 2 * 3600 + + /// Reconcile every live job activity against the latest app state. Safe + /// from any trigger except the `$sessions` sink, where app state has not + /// yet committed the new value — pass it explicitly there instead. + private func reconcileActivities() { + guard #available(iOS 16.2, *) else { return } + reconcileActivities(for: state?.sessions ?? []) + } + + /// Heal a job Live Activity the desktop left stalled. + /// + /// The desktop drives the aggregate activity over APNs and never ends it + /// itself, so a finishing `update` that is lost — a dropped push, or a + /// desktop that crashed or quit mid-job — pins the activity on "Working" + /// forever. This reconciles it against the two truths the device still + /// holds: + /// + /// - the desktop's mirrored session list, delivered over the live relay: + /// a job the activity still calls `running` whose session the desktop + /// reports present-and-not-streaming has actually finished — flip it to + /// `done`. An absent session is left untouched: the mirror may simply + /// be incomplete, which is not evidence the job finished. + /// - the relay connection: if the relay is down and the activity has not + /// been refreshed within `staleActivityEndThreshold`, the desktop is + /// unreachable and the activity can never recover — end it. + @available(iOS 16.2, *) + private func reconcileActivities(for sessions: [SessionSummary]) { + let activities = Activity.activities + guard !activities.isEmpty else { return } + let relayConnected: Bool = { + if case .connected = state?.connectionState { return true } + return false + }() + let now = Date() + for activity in activities { + let content = activity.content.state + // A done activity is a valid terminal state — leave it alone. + guard content.deduplicatedJobs.contains(where: { $0.phase == .running }) else { + continue + } + + // The desktop is unreachable and the activity has gone stale well + // past any live job's update cadence — it can never recover. End + // it rather than leave a dead "Working" indicator on screen. + let age = now.timeIntervalSince1970 - content.updatedAt + if !relayConnected, age > staleActivityEndThreshold { + logger.warning("[LiveActivity] ending stalled activity id=\(activity.id, privacy: .public) — relay down, \(Int(age), privacy: .public)s since last desktop update") + Task { await activity.end(nil, dismissalPolicy: .immediate) } + continue + } + + // The desktop is reachable: trust its mirrored session list. + guard relayConnected else { continue } + var jobs = content.jobs + var healed = false + for idx in jobs.indices where jobs[idx].phase == .running { + guard let session = sessions.first(where: { $0.id == jobs[idx].id }), + !session.isStreaming + else { continue } + jobs[idx].phase = .done + healed = true + } + guard healed else { continue } + let healedState = RxCodeJobActivityAttributes.ContentState( + jobs: jobs, updatedAt: now.timeIntervalSince1970 + ) + logger.warning("[LiveActivity] healing stalled activity id=\(activity.id, privacy: .public) — desktop's finishing update was lost; marking finished jobs done") + Task { + await activity.update(ActivityContent(state: healedState, staleDate: nil)) + } + } + } + /// Ensure at most one Live Activity exists. iOS can still spawn a second /// one — the desktop's push-to-start travels over APNs, not the relay, so /// it can race a foreground local start when the relay briefly drops — so