434 lines
23 KiB
Swift
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) }
|
|
}
|
|
}
|