Files

434 lines
23 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import GRDB
import TermindApplication
/// A single actor owns the connection and every file in its dedicated directory.
/// Opening is lazy: constructing a disabled feature performs no filesystem work.
public actor GRDBMemoryStore: MemoryPersisting {
private let directory: URL
private var queue: DatabaseQueue?
public init(directory: URL) { self.directory = directory }
public nonisolated static var defaultDirectory: URL {
URL.applicationSupportDirectory.appending(path: "Termind/HermesMemory", directoryHint: .isDirectory)
}
private func database() throws -> DatabaseQueue {
try Task.checkCancellation()
if let queue { return queue }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var configuration = Configuration()
configuration.prepareDatabase { db in
try db.execute(sql: "PRAGMA journal_mode = WAL")
db.add(
function: DatabaseFunction("termindCaseFold", argumentCount: 1, pure: true) { values in
guard let text = String.fromDatabaseValue(values[0]) else { return nil }
return text.folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
})
}
let opened = try DatabaseQueue(
path: directory.appending(path: "memory.sqlite").path, configuration: configuration)
var migrator = DatabaseMigrator()
migrator.registerMigration("hermes-v1") { db in
try db.execute(
sql: """
CREATE TABLE memory_state (id INTEGER PRIMARY KEY CHECK(id=1), data BLOB NOT NULL);
CREATE TABLE snapshots (conversation_id TEXT PRIMARY KEY, content TEXT NOT NULL);
CREATE TABLE reviews (conversation_id TEXT PRIMARY KEY, message_id TEXT NOT NULL);
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL, modified REAL NOT NULL);
CREATE TABLE messages (id TEXT NOT NULL, session_id TEXT NOT NULL, position INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, created REAL NOT NULL,
PRIMARY KEY(session_id, id));
CREATE VIRTUAL TABLE message_fts USING fts5(content, content='messages', content_rowid='rowid', tokenize='trigram');
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO message_fts(rowid, content) VALUES (new.rowid, new.content); END;
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO message_fts(message_fts, rowid, content) VALUES ('delete', old.rowid, old.content); END;
CREATE INDEX messages_session_position ON messages(session_id, position);
""")
}
migrator.registerMigration("hermes-policy-learning-v2") { db in
try db.execute(sql: "DROP TABLE snapshots")
try db.execute(
sql: """
CREATE TABLE learning_activity (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
event_id TEXT NOT NULL,
source_message_id TEXT NOT NULL,
kind TEXT NOT NULL,
reviewed INTEGER NOT NULL DEFAULT 0,
UNIQUE(conversation_id,event_id,kind));
CREATE INDEX learning_activity_conversation ON learning_activity(conversation_id,sequence);
CREATE TABLE reviewed_corrections (
conversation_id TEXT NOT NULL,
message_id TEXT NOT NULL,
PRIMARY KEY(conversation_id,message_id));
""")
}
migrator.registerMigration("hermes-reference-dates-v3") { db in
try db.execute(
sql: "CREATE TABLE memory_references (entry_id TEXT PRIMARY KEY, last_referenced REAL NOT NULL)")
}
try migrator.migrate(opened)
queue = opened
return opened
}
private nonisolated static func state(_ db: Database) throws -> AgentMemoryState {
guard let bytes = try Data.fetchOne(db, sql: "SELECT data FROM memory_state WHERE id=1") else { return .init() }
return try JSONDecoder().decode(AgentMemoryState.self, from: bytes)
}
public func prepareSession(_ conversationID: UUID) throws { _ = try database() }
public func memoryState() throws -> AgentMemoryState { try database().read(Self.state) }
public func memoryReferenceDates() throws -> [UUID: Date] {
try database().read { db in
let rows = try Row.fetchAll(db, sql: "SELECT entry_id,last_referenced FROM memory_references")
return Dictionary(
uniqueKeysWithValues: rows.compactMap { row -> (UUID, Date)? in
guard let id = UUID(uuidString: row["entry_id"]) else { return nil }
return (id, Date(timeIntervalSince1970: row["last_referenced"]))
})
}
}
public func recordMemoryReferences(_ ids: [UUID], at date: Date) throws {
guard !ids.isEmpty else { return }
try database().write { db in
let valid = Set(try Self.state(db).entries.map(\.id))
for id in Set(ids) where valid.contains(id) {
try db.execute(
sql: """
INSERT INTO memory_references(entry_id,last_referenced) VALUES(?,?)
ON CONFLICT(entry_id) DO UPDATE SET last_referenced=MAX(last_referenced,excluded.last_referenced)
""",
arguments: [id.uuidString, date.timeIntervalSince1970])
}
}
}
private nonisolated static func save(_ state: AgentMemoryState, in db: Database) throws {
try db.execute(
sql: "INSERT OR REPLACE INTO memory_state(id,data) VALUES(1,?)",
arguments: [try JSONEncoder().encode(state)])
let valid = Set(state.entries.map { $0.id.uuidString })
for id in try String.fetchAll(db, sql: "SELECT entry_id FROM memory_references") where !valid.contains(id) {
try db.execute(sql: "DELETE FROM memory_references WHERE entry_id=?", arguments: [id])
}
}
public func replaceTarget(_ target: AgentMemoryTarget, text: String) throws -> AgentMemoryState {
try database().write { db in
var current = try Self.state(db)
var previous = current.entries.filter { $0.target == target }
let contents = try text.components(separatedBy: "\n§\n").map(MemoryDateAnnotations.content(from:))
.filter { !$0.isEmpty }
var matched: [Int: AgentMemoryEntry] = [:]
for (index, content) in contents.enumerated() {
if let oldIndex = previous.firstIndex(where: { $0.content == content }) {
matched[index] = previous.remove(at: oldIndex)
}
}
let unmatched = contents.indices.filter { matched[$0] == nil }
if previous.count == unmatched.count {
for (index, old) in zip(unmatched, previous) { matched[index] = old }
}
current.entries.removeAll { $0.target == target }
for (index, content) in contents.enumerated() {
let count = current.entries.count
current = try current.applying([.init(action: .add, target: target, content: content)])
if current.entries.count > count, let old = matched[index] {
current.entries[current.entries.count - 1].id = old.id
current.entries[current.entries.count - 1].createdAt = old.createdAt
current.entries[current.entries.count - 1].updatedAt =
old.content == content ? old.updatedAt : .now
}
}
try Self.save(current, in: db)
return current
}
}
public func applyReview(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState, messageID: UUID, conversationID: UUID,
reviewedActivityIDs: [UUID]
) throws -> AgentMemoryState? {
try database().write { db in
let current = try Self.state(db)
guard current == expected else { return nil }
let updated = operations.isEmpty ? current : try current.applying(operations)
try Self.save(updated, in: db)
try db.execute(
sql: "INSERT OR REPLACE INTO reviews VALUES(?,?)",
arguments: [conversationID.uuidString, messageID.uuidString])
for id in reviewedActivityIDs {
try db.execute(
sql: "UPDATE learning_activity SET reviewed=1 WHERE conversation_id=? AND event_id=?",
arguments: [conversationID.uuidString, id.uuidString])
}
return updated
}
}
public func applyMemory(_ operations: [AgentMemoryOperation]) throws -> AgentMemoryState {
try database().write { db in
let state = try Self.state(db).applying(operations)
try Self.save(state, in: db)
return state
}
}
public func lastReviewedMessage(_ conversationID: UUID) throws -> UUID? {
try database().read { db in
try String.fetchOne(
db, sql: "SELECT message_id FROM reviews WHERE conversation_id=?",
arguments: [conversationID.uuidString]
)
.flatMap(UUID.init(uuidString:))
}
}
public func recordActivity(
_ id: UUID, sourceMessageID: UUID, kind: MemoryActivityKind,
conversationID: UUID
) throws
-> MemoryLearningProgress
{
try database().write { db in
try db.execute(
sql:
"INSERT OR IGNORE INTO learning_activity(conversation_id,event_id,source_message_id,kind) VALUES(?,?,?,?)",
arguments: [
conversationID.uuidString, id.uuidString, sourceMessageID.uuidString,
kind.rawValue,
])
return try Self.learningProgress(db, conversationID: conversationID)
}
}
public func learningProgress(_ conversationID: UUID) throws -> MemoryLearningProgress {
try database().read { try Self.learningProgress($0, conversationID: conversationID) }
}
private nonisolated static func learningProgress(_ db: Database, conversationID: UUID) throws
-> MemoryLearningProgress
{
let rows = try Row.fetchAll(
db,
sql:
"SELECT event_id,source_message_id,kind FROM learning_activity WHERE conversation_id=? AND reviewed=0 ORDER BY sequence",
arguments: [conversationID.uuidString])
let cursor = try String.fetchOne(
db, sql: "SELECT message_id FROM reviews WHERE conversation_id=?",
arguments: [conversationID.uuidString]
).flatMap(UUID.init(uuidString:))
return MemoryLearningProgress(
lastReviewedMessage: cursor,
turns: rows.filter { ($0["kind"] as String) == MemoryActivityKind.turn.rawValue }.count,
tools: rows.filter { ($0["kind"] as String) == MemoryActivityKind.tool.rawValue }.count,
events: rows.compactMap { row in
guard let id = UUID(uuidString: row["event_id"]),
let source = UUID(uuidString: row["source_message_id"]),
MemoryActivityKind(rawValue: row["kind"]) != nil
else { return nil }
return MemoryLearningEvent(id: id, sourceMessageID: source)
})
}
public func correctionWasReviewed(_ messageID: UUID, conversationID: UUID) throws -> Bool {
try database().read { db in
try Int.fetchOne(
db, sql: "SELECT 1 FROM reviewed_corrections WHERE conversation_id=? AND message_id=?",
arguments: [conversationID.uuidString, messageID.uuidString]) != nil
}
}
public func applyCorrection(
_ operations: [AgentMemoryOperation], expected: AgentMemoryState, messageID: UUID, conversationID: UUID
) throws -> AgentMemoryState? {
try database().write { db in
let current = try Self.state(db)
guard current == expected else { return nil }
let updated = operations.isEmpty ? current : try current.applying(operations)
try Self.save(updated, in: db)
try db.execute(
sql: "INSERT OR IGNORE INTO reviewed_corrections VALUES(?,?)",
arguments: [conversationID.uuidString, messageID.uuidString])
return updated
}
}
public func replaceHistory(_ conversations: [StoredAgentConversation]) async throws {
let database = try database()
let ids = Set(conversations.map { $0.id.uuidString })
try await database.write { db in
for id in try String.fetchAll(db, sql: "SELECT id FROM sessions") where !ids.contains(id) {
try db.execute(sql: "DELETE FROM messages WHERE session_id=?", arguments: [id])
try db.execute(sql: "DELETE FROM sessions WHERE id=?", arguments: [id])
try db.execute(sql: "DELETE FROM reviews WHERE conversation_id=?", arguments: [id])
try db.execute(sql: "DELETE FROM learning_activity WHERE conversation_id=?", arguments: [id])
try db.execute(sql: "DELETE FROM reviewed_corrections WHERE conversation_id=?", arguments: [id])
}
}
for (batchIndex, conversation) in conversations.enumerated() {
if batchIndex.isMultiple(of: 50) { await Task.yield() }
try Task.checkCancellation()
try await database.write { db in
let id = conversation.id.uuidString
let messages = conversation.messages.filter {
!$0.isCompaction && ($0.role == .user || $0.role == .assistant || $0.role == .tool)
}
// Compare persisted content, not just timestamps: edit/truncate can retain timestamps.
let existing = try Row.fetchAll(
db, sql: "SELECT id,content FROM messages WHERE session_id=? ORDER BY position", arguments: [id])
let clean = messages.map { message in
let calls = message.toolCalls.map { call in
call.name + " " + AgentSensitiveDataRedactor.redactedJSON(call.arguments).encodedString
}.joined(separator: "\n")
return AgentPersistenceRedactor.visibleText(message.text + (calls.isEmpty ? "" : "\n" + calls))
}
let unchanged =
existing.count == messages.count
&& zip(existing, messages.indices).allSatisfy {
($0.0["id"] as String) == messages[$0.1].id.uuidString
&& ($0.0["content"] as String) == clean[$0.1]
}
try db.execute(
sql: "INSERT OR REPLACE INTO sessions VALUES(?,?,?)",
arguments: [id, conversation.title, conversation.metadata.updatedAt.timeIntervalSince1970])
guard !unchanged else { return }
try db.execute(sql: "DELETE FROM messages WHERE session_id=?", arguments: [id])
for (index, message) in messages.enumerated() {
try db.execute(
sql: "INSERT INTO messages(id,session_id,position,role,content,created) VALUES(?,?,?,?,?,?)",
arguments: [
message.id.uuidString, id, index, message.role.rawValue, clean[index],
message.createdAt.timeIntervalSince1970,
])
}
if conversation.lastRunState.isTerminal {
try db.execute(
sql: """
DELETE FROM learning_activity WHERE conversation_id=?
AND source_message_id NOT IN (SELECT id FROM messages WHERE session_id=?)
""", arguments: [id, id])
try db.execute(
sql: """
DELETE FROM reviewed_corrections WHERE conversation_id=?
AND message_id NOT IN (SELECT id FROM messages WHERE session_id=?)
""", arguments: [id, id])
try db.execute(
sql: """
DELETE FROM reviews WHERE conversation_id=?
AND message_id NOT IN (SELECT id FROM messages WHERE session_id=?)
""", arguments: [id, id])
}
}
}
}
public func searchSessions(_ request: AgentSessionSearchRequest) throws -> AgentJSONValue {
let limit = min(20, max(1, request.limit))
let offset = max(0, request.offset)
let rawQuery = request.query?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return try database().read { db in
if request.conversationID == nil, rawQuery.isEmpty {
let rows = try Row.fetchAll(
db, sql: "SELECT * FROM sessions ORDER BY modified DESC LIMIT ? OFFSET ?",
arguments: [limit + 1, offset])
return .object([
"sessions": .array(
rows.prefix(limit).map {
.object(["conversation_id": .string($0["id"]), "title": .string($0["title"])])
}), "next_offset": rows.count > limit ? .number(Double(offset + limit)) : .null,
])
}
let rows: [Row]
if rawQuery.isEmpty, let id = request.conversationID {
let sql = """
SELECT m.*,s.title FROM messages m JOIN sessions s ON s.id=m.session_id
WHERE m.session_id=? ORDER BY m.position LIMIT ? OFFSET ?
"""
rows = try Row.fetchAll(db, sql: sql, arguments: [id.uuidString, limit + 1, offset])
} else {
let query = AgentSearchQuery(rawQuery)
guard !query.terms.isEmpty else {
return .object(["messages": .array([]), "next_offset": .null])
}
func plan(matchingAll: Bool) -> (
fromWhere: String, order: String, arguments: StatementArguments, rankTerms: [String]
) {
var arguments = StatementArguments()
let fromWhere: String
let order: String
let rankTerms: [String]
if let fts = query.fts5Query(matchingAll: matchingAll) {
fromWhere = """
FROM message_fts JOIN messages m ON m.rowid=message_fts.rowid
JOIN sessions s ON s.id=m.session_id WHERE message_fts MATCH ?
"""
arguments += [fts]
order = "bm25(message_fts),s.modified DESC,m.session_id,m.position,m.id"
rankTerms = []
} else {
let predicate = Array(
repeating: "instr(termindCaseFold(m.content),termindCaseFold(?))>0",
count: query.terms.count
)
.joined(separator: matchingAll ? " AND " : " OR ")
fromWhere = """
FROM messages m JOIN sessions s ON s.id=m.session_id WHERE (\(predicate))
"""
for term in query.terms { arguments += [term] }
let termScores = Array(
repeating:
"(CASE WHEN instr(termindCaseFold(m.content),termindCaseFold(?))>0 THEN 1 ELSE 0 END)",
count: query.terms.count)
order = """
(CASE WHEN instr(termindCaseFold(m.content),termindCaseFold(?))>0
OR instr(termindCaseFold(m.content),termindCaseFold(?))>0 THEN 1 ELSE 0 END) DESC,
(\(termScores.joined(separator: " + "))) DESC,
s.modified DESC,m.session_id,m.position,m.id
"""
rankTerms = [query.terms.joined(separator: " "), query.terms.joined()] + query.terms
}
if let id = request.conversationID {
arguments += [id.uuidString]
return (fromWhere + " AND m.session_id=?", order, arguments, rankTerms)
}
return (fromWhere, order, arguments, rankTerms)
}
// Decide the fallback for the whole result set, not this page's offset.
let strict = plan(matchingAll: true)
let hasStrict =
try Int.fetchOne(
db, sql: "SELECT 1 \(strict.fromWhere) LIMIT 1", arguments: strict.arguments) != nil
let selected = hasStrict ? strict : plan(matchingAll: false)
var arguments = selected.arguments
for term in selected.rankTerms { arguments += [term] }
arguments += [limit + 1, offset]
rows = try Row.fetchAll(
db,
sql: "SELECT m.*,s.title \(selected.fromWhere) ORDER BY \(selected.order) LIMIT ? OFFSET ?",
arguments: arguments)
}
return .object([
"messages": .array(
rows.prefix(limit).map { row in
.object([
"conversation_id": .string(row["session_id"]), "message_id": .string(row["id"]),
"title": .string(row["title"]), "role": .string(row["role"]),
"position": .number(Double(row["position"] as Int)),
"content": .string(row["content"]),
])
}), "next_offset": rows.count > limit ? .number(Double(offset + limit)) : .null,
])
}
}
public func rebuildHistoryIndex() throws {
try database().write { db in
try db.execute(sql: "INSERT INTO message_fts(message_fts) VALUES('rebuild')")
}
}
public func close() throws {
try queue?.close()
queue = nil
}
public func destroy() throws {
try close()
if FileManager.default.fileExists(atPath: directory.path) { try FileManager.default.removeItem(at: directory) }
}
}