Add opt-in GRDB memory with frozen conversation snapshots, background learning, session search, and a user-only Destroy action on both platforms. Persist and synchronize complete skill packages with the new AgentKit management tools. Pin signed AgentKit 0.12.3 and GRDB 7.11.1. Add the V18 package migration, regression coverage, bilingual UI snapshots, and architecture records. Validation: AgentKit and iOS suites, focused macOS skills/memory/architecture/localization tests, Swift formatting, and Chinese localization checks passed. Full macOS runs exposed intermittent existing waveform-rendering and metrics-timing failures.
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindDomain
|
|
|
|
@Observable @MainActor
|
|
public final class MemoryModel {
|
|
public private(set) var isEnabled: Bool
|
|
public private(set) var isBusy = false
|
|
public private(set) var isIndexing = false
|
|
public private(set) var state = AgentMemoryState()
|
|
public private(set) var error: String?
|
|
public var reviewEnabled: Bool {
|
|
didSet {
|
|
defaults?.set(reviewEnabled, forKey: Self.reviewKey)
|
|
if !reviewEnabled {
|
|
for task in reviews.values { task.cancel() }
|
|
pendingReviews.removeAll()
|
|
}
|
|
}
|
|
}
|
|
public var reviewModel: AIModelRef? {
|
|
didSet { defaults?.set(try? JSONEncoder().encode(reviewModel), forKey: Self.modelKey) }
|
|
}
|
|
private static let enabledKey = "hermesMemoryEnabled"
|
|
private static let destroyingKey = "hermesMemoryDestroyPending"
|
|
private static let reviewKey = "hermesMemoryReviewEnabled"
|
|
private static let modelKey = "hermesMemoryReviewModel"
|
|
private let defaults: UserDefaults?
|
|
private let storage: (any MemoryPersisting)?
|
|
private var generation = UUID()
|
|
private var contexts: [UUID: AgentMemoryContext] = [:]
|
|
private var history: (any AgentRunPersisting)?
|
|
private var indexTask: Task<Void, Never>?
|
|
private var indexDirty = false
|
|
private var reviews: [UUID: Task<Void, Never>] = [:]
|
|
private var pendingReviews: [UUID: (@Sendable () async -> Void)] = [:]
|
|
|
|
public init(storage: (any MemoryPersisting)? = nil, defaults: UserDefaults? = nil) {
|
|
self.storage = storage
|
|
self.defaults = defaults
|
|
let pending = defaults?.bool(forKey: Self.destroyingKey) == true
|
|
isEnabled = !pending && storage != nil && defaults?.bool(forKey: Self.enabledKey) == true
|
|
reviewEnabled = defaults?.object(forKey: Self.reviewKey) as? Bool ?? true
|
|
reviewModel = defaults?.data(forKey: Self.modelKey).flatMap {
|
|
try? JSONDecoder().decode(AIModelRef.self, from: $0)
|
|
}
|
|
if pending { Task { await destroy() } }
|
|
}
|
|
public func attachHistory(_ history: any AgentRunPersisting) {
|
|
self.history = history
|
|
historyChanged()
|
|
}
|
|
private func invalidate() {
|
|
generation = UUID()
|
|
for context in contexts.values { context.invalidate() }
|
|
contexts.removeAll()
|
|
indexTask?.cancel()
|
|
for task in reviews.values { task.cancel() }
|
|
pendingReviews.removeAll()
|
|
}
|
|
public func setEnabled(_ enabled: Bool) async {
|
|
guard !isBusy, let storage, defaults?.bool(forKey: Self.destroyingKey) != true else { return }
|
|
isBusy = true
|
|
defer { isBusy = false }
|
|
invalidate()
|
|
isEnabled = enabled
|
|
defaults?.set(enabled, forKey: Self.enabledKey)
|
|
error = nil
|
|
do {
|
|
if enabled {
|
|
state = try await storage.memoryState()
|
|
historyChanged()
|
|
} else {
|
|
try await storage.close()
|
|
}
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
public func refresh() async {
|
|
guard isEnabled, let storage else { return }
|
|
let token = generation
|
|
do {
|
|
let value = try await storage.memoryState()
|
|
if token == generation {
|
|
state = value
|
|
error = nil
|
|
}
|
|
} catch { if token == generation { self.error = error.localizedDescription } }
|
|
}
|
|
public func edit(_ target: AgentMemoryTarget, text: String) async {
|
|
guard isEnabled, let storage else { return }
|
|
let token = generation
|
|
do {
|
|
let updated = try await storage.replaceTarget(target, text: text)
|
|
guard token == generation, isEnabled else { return }
|
|
state = updated
|
|
error = nil
|
|
} catch { if token == generation { self.error = error.localizedDescription } }
|
|
}
|
|
public func session(_ id: UUID) async -> (context: AgentMemoryContext, access: any AgentMemoryAccessing)? {
|
|
guard isEnabled, let storage else { return nil }
|
|
let token = generation
|
|
do {
|
|
let context: AgentMemoryContext
|
|
if let existing = contexts[id] {
|
|
context = existing
|
|
} else {
|
|
let snapshot = try await storage.snapshot(for: id)
|
|
guard token == generation, isEnabled else { return nil }
|
|
context = contexts[id] ?? AgentMemoryContext(snapshot: snapshot)
|
|
contexts[id] = context
|
|
}
|
|
return (context, MemoryAccess(model: self, token: token))
|
|
} catch {
|
|
if token == generation { self.error = error.localizedDescription }
|
|
return nil
|
|
}
|
|
}
|
|
private func require(_ token: UUID) throws -> any MemoryPersisting {
|
|
try Task.checkCancellation()
|
|
guard isEnabled, token == generation, !isBusy, let storage else { throw CancellationError() }
|
|
return storage
|
|
}
|
|
fileprivate func read(token: UUID) async throws -> AgentMemoryState {
|
|
let result = try await require(token).memoryState()
|
|
_ = try require(token)
|
|
return result
|
|
}
|
|
@discardableResult
|
|
fileprivate func apply(_ operations: [AgentMemoryOperation], token: UUID) async throws -> AgentMemoryState {
|
|
let result = try await require(token).applyMemory(operations)
|
|
_ = try require(token)
|
|
state = result
|
|
return result
|
|
}
|
|
fileprivate func search(_ request: AgentSessionSearchRequest, token: UUID) async throws -> AgentJSONValue {
|
|
_ = try require(token)
|
|
historyChanged()
|
|
await indexTask?.value
|
|
let result = try await require(token).searchSessions(request)
|
|
_ = try require(token)
|
|
return result
|
|
}
|
|
public func rebuildHistoryIndex() async {
|
|
guard isEnabled else { return }
|
|
let token = generation
|
|
historyChanged()
|
|
await indexTask?.value
|
|
do {
|
|
try await require(token).rebuildHistoryIndex()
|
|
if token == generation { error = nil }
|
|
} catch { if token == generation { self.error = error.localizedDescription } }
|
|
}
|
|
|
|
public func historyChanged() {
|
|
guard isEnabled, let storage, let history else { return }
|
|
indexDirty = true
|
|
guard indexTask == nil else { return }
|
|
let token = generation
|
|
isIndexing = true
|
|
indexTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer {
|
|
indexTask = nil
|
|
isIndexing = false
|
|
if indexDirty, isEnabled { historyChanged() }
|
|
}
|
|
do {
|
|
while indexDirty, token == generation, isEnabled, !Task.isCancelled {
|
|
indexDirty = false
|
|
let conversations = try await history.loadConversations()
|
|
_ = try require(token)
|
|
try await storage.replaceHistory(conversations)
|
|
}
|
|
} catch is CancellationError {
|
|
} catch {
|
|
if token == generation {
|
|
self.error = error.localizedDescription
|
|
indexDirty = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
public func review(
|
|
conversationID: UUID, messages: [AgentTranscriptMessage], model: any AgentModelCompleting,
|
|
wait: Bool = false
|
|
) async {
|
|
guard isEnabled, reviewEnabled, let storage, let last = messages.last?.id else { return }
|
|
let token = generation
|
|
if let running = reviews[conversationID] {
|
|
if wait {
|
|
await running.value
|
|
} else {
|
|
pendingReviews[conversationID] = { [weak self] in
|
|
await self?.review(conversationID: conversationID, messages: messages, model: model)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
guard token == generation, isEnabled else { return }
|
|
let task = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer {
|
|
reviews.removeValue(forKey: conversationID)
|
|
if let next = pendingReviews.removeValue(forKey: conversationID) { Task { await next() } }
|
|
}
|
|
do {
|
|
guard try await storage.lastReviewedMessage(conversationID) != last else { return }
|
|
let current = try await read(token: token)
|
|
let operations = try await withThrowingTaskGroup(of: [AgentMemoryOperation].self) { group in
|
|
group.addTask {
|
|
try await AgentMemoryLearningService().review(messages: messages, state: current, model: model)
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(for: .seconds(60))
|
|
throw CancellationError()
|
|
}
|
|
defer { group.cancelAll() }
|
|
return try await group.next() ?? []
|
|
}
|
|
_ = try require(token)
|
|
if let updated = try await storage.applyReview(
|
|
operations, expected: current, messageID: last, conversationID: conversationID)
|
|
{
|
|
_ = try require(token)
|
|
state = updated
|
|
}
|
|
} catch is CancellationError {
|
|
} catch { if token == generation { self.error = error.localizedDescription } }
|
|
}
|
|
reviews[conversationID] = task
|
|
if wait { await task.value }
|
|
}
|
|
public func destroy() async {
|
|
guard !isBusy, let storage else { return }
|
|
isBusy = true
|
|
isEnabled = false
|
|
defaults?.set(false, forKey: Self.enabledKey)
|
|
defaults?.set(true, forKey: Self.destroyingKey)
|
|
invalidate()
|
|
indexDirty = false
|
|
let pending = Array(reviews.values)
|
|
await indexTask?.value
|
|
for task in pending { await task.value }
|
|
do {
|
|
try await storage.destroy()
|
|
state = .init()
|
|
error = nil
|
|
defaults?.removeObject(forKey: Self.destroyingKey)
|
|
} catch { self.error = error.localizedDescription }
|
|
isBusy = false
|
|
}
|
|
public func suspend() {
|
|
indexTask?.cancel()
|
|
for task in reviews.values { task.cancel() }
|
|
pendingReviews.removeAll()
|
|
}
|
|
}
|
|
|
|
private struct MemoryAccess: AgentMemoryAccessing {
|
|
weak var model: MemoryModel?
|
|
let token: UUID
|
|
func memoryState() async throws -> AgentMemoryState {
|
|
guard let model else { throw CancellationError() }
|
|
return try await model.read(token: token)
|
|
}
|
|
func applyMemory(_ operations: [AgentMemoryOperation]) async throws -> AgentMemoryState {
|
|
guard let model else { throw CancellationError() }
|
|
return try await model.apply(operations, token: token)
|
|
}
|
|
func searchSessions(_ request: AgentSessionSearchRequest) async throws -> AgentJSONValue {
|
|
guard let model else { throw CancellationError() }
|
|
return try await model.search(request, token: token)
|
|
}
|
|
}
|