Files
Termind/Frameworks/TermindApplication/FeatureModels/MemoryModel.swift
T
Kaede Akino 8d591dd0f6 Add global Hermes memory and skill package tools
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.
2026-09-17 07:54:04 +08:00

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)
}
}