Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ All notable changes to Engram are documented in this file.

## [Unreleased]

## [0.14.10] - 2026-09-24

### Fixed
- Report memory updates as successful only after a checked transaction commits on the database connection that owns the selected memory, including synced and group memories.
- Recognize update attempts that stop before a busy database transaction begins as verified no-write failures; preserve reconciliation for uncertain writes.
- Check edge restoration during undelete and propagate failures without claiming that an already committed memory update wrote nothing.
- Show Codex session learners only the write options their restricted gateway accepts, including exact memory IDs and preserved privacy for updates.
- Instruct Codex session learners to stop making tool calls after an MCP error, avoiding follow-up writes after a rejected call; conservative completion and reconciliation checks remain unchanged.

Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ let package = Package(
"EngramMemoryCore",
"EngramMemoryContract",
.product(name: "Lattice", package: "lattice"),
.product(name: "SwiftLM", package: "SwiftLM"),
],
swiftSettings: [
.interoperabilityMode(.Cxx),
Expand Down
171 changes: 103 additions & 68 deletions Sources/EngramKit/MemoryTools+Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -999,11 +999,13 @@ extension MemoryTools {

// 5. Locate memory
let mem: Memory
let writeLattice: Lattice
if let gid = a.id?.value {
guard let found = findMemory(id: gid) else {
return CallTool.Result(content: [.text("Memory with id \(gid.uuidString) not found.")], isError: true)
}
mem = found.memory
writeLattice = found.lattice
} else {
let query = a.query!
let db = readLattice(for: a.project)
Expand All @@ -1020,6 +1022,10 @@ extension MemoryTools {
return CallTool.Result(content: [.text("No matching memory found to update.")], isError: false)
}
mem = match.object
// Keep the connection that hydrated the selected row. A union
// query can return an attached-store row whose setters must be
// covered by this same connection's checked transaction.
writeLattice = db
}

// 5b. Tombstone gate: a soft-deleted memory only accepts undelete —
Expand Down Expand Up @@ -1078,79 +1084,106 @@ extension MemoryTools {
}
}

try localLattice.transaction {
if let content = a.content {
mem.content = content
contentChanged = true
} else if let append = a.append {
mem.content += "\n" + append
contentChanged = true
} else if let prepend = a.prepend {
mem.content = prepend + "\n" + mem.content
contentChanged = true
} else if let find = a.find {
let replace = a.replace!
mem.content = mem.content.replacingOccurrences(of: find, with: replace)
contentChanged = true
}
var transactionBodyEntered = false
do {
try writeLattice.withTransaction {
transactionBodyEntered = true
if let content = a.content {
mem.content = content
contentChanged = true
} else if let append = a.append {
mem.content += "\n" + append
contentChanged = true
} else if let prepend = a.prepend {
mem.content = prepend + "\n" + mem.content
contentChanged = true
} else if let find = a.find {
let replace = a.replace!
mem.content = mem.content.replacingOccurrences(of: find, with: replace)
contentChanged = true
}

if contentChanged {
changes.append("content: \(oldContent.prefix(60))... → \(mem.content.prefix(60))...")
}
if contentChanged {
changes.append("content: \(oldContent.prefix(60))... → \(mem.content.prefix(60))...")
}

if let project = a.setProject {
let old = mem.project
mem.project = project
changes.append("project: \(old) → \(project)")
}
if let topic = a.topic {
let old = mem.topic
mem.topic = topic
changes.append("topic: \(old) → \(topic)")
}
if let source = a.source {
let old = mem.source
mem.source = source
changes.append("source: \(old) → \(source)")
}
if let days = a.expiresInDays?.value {
let oldExpires = mem.expiresAt == .distantFuture ? "permanent" : Self.dateFormatter.string(from: mem.expiresAt)
if days == 0 {
mem.expiresAt = .distantFuture
changes.append("expires: \(oldExpires) → permanent")
} else {
mem.expiresAt = Date().addingTimeInterval(Double(days) * 86400)
changes.append("expires: \(oldExpires) → \(Self.dateFormatter.string(from: mem.expiresAt))")
if let project = a.setProject {
let old = mem.project
mem.project = project
changes.append("project: \(old) → \(project)")
}
}
if let imp = a.importance?.value {
let old = mem.importance
mem.importance = imp
changes.append("importance: \(old) → \(imp)")
}
if let priv = a.isPrivate {
let old = mem.isPrivate
mem.isPrivate = priv
changes.append("private: \(old) → \(priv)")
if priv && !old && isGroupShared(mem) {
changes.append("⚠️ retracted from the group: the group's copy (including any teammate edits) is removed for all members")
if let topic = a.topic {
let old = mem.topic
mem.topic = topic
changes.append("topic: \(old) → \(topic)")
}
if let source = a.source {
let old = mem.source
mem.source = source
changes.append("source: \(old) → \(source)")
}
if let days = a.expiresInDays?.value {
let oldExpires = mem.expiresAt == .distantFuture ? "permanent" : Self.dateFormatter.string(from: mem.expiresAt)
if days == 0 {
mem.expiresAt = .distantFuture
changes.append("expires: \(oldExpires) → permanent")
} else {
mem.expiresAt = Date().addingTimeInterval(Double(days) * 86400)
changes.append("expires: \(oldExpires) → \(Self.dateFormatter.string(from: mem.expiresAt))")
}
}
if let imp = a.importance?.value {
let old = mem.importance
mem.importance = imp
changes.append("importance: \(old) → \(imp)")
}
if let priv = a.isPrivate {
let old = mem.isPrivate
mem.isPrivate = priv
changes.append("private: \(old) → \(priv)")
if priv && !old && isGroupShared(mem) {
changes.append("⚠️ retracted from the group: the group's copy (including any teammate edits) is removed for all members")
}
}
if a.undelete == true, mem.deletedAt != nil {
mem.deletedAt = nil
mem.deletedBy = nil
didUndelete = true
changes.append("undeleted (restored for all members)")
}
}
if a.undelete == true, mem.deletedAt != nil {
mem.deletedAt = nil
mem.deletedBy = nil
didUndelete = true
changes.append("undeleted (restored for all members)")
}

if contentChanged, let emb = newEmbedding {
mem.embedding = Vector<Float>(emb)
}
if contentChanged, let emb = newEmbedding {
mem.embedding = Vector<Float>(emb)
}

mem.lastAccessedAt = Date()
// authorUserId is NEVER touched by edits — attribution follows
// the original author, and the sync firewall keys on it.
mem.modifiedAt = Date()
mem.lastAccessedAt = Date()
// authorUserId is NEVER touched by edits — attribution follows
// the original author, and the sync firewall keys on it.
mem.modifiedAt = Date()
}
} catch {
// Only a failed BEGIN proves that this attempt wrote nothing.
// Setter, COMMIT, rollback and notification failures retain an
// unknown outcome; never turn those into a safe-retry receipt.
guard !transactionBodyEntered,
case LatticeError.transactionError(let detail) = error,
["database is locked", "database is busy", "database table is locked",
"database schema is locked"].contains(where: {
detail == "Failed to begin transaction: " + $0
}) else { throw error }
return CallTool.Result(
content: [.text("Memory was not updated: the database was busy before the write transaction started. Retry on a later turn.")],
structuredContent: .object([
"engram_write_receipt": .object([
"schema_version": .int(1),
"tool": .string("update"),
"write_outcome": .string("not_stored_transaction_not_started"),
"reason": .string("database_busy"),
"memory_ids": .array([]),
]),
]),
isError: true
)
}

// Restore graph connectivity alongside the memory: edges tombstoned
Expand All @@ -1159,7 +1192,9 @@ extension MemoryTools {
// (didUndelete, not a mem.deletedAt re-read — the materialized
// snapshot can serve the stale pre-transaction value.)
if didUndelete, let gid = mem.globalId {
let revived = reviveEdgesForMemory(gid)
// The memory is already committed. A later graph failure must
// propagate as an uncertain partial outcome, never as no write.
let revived = try reviveEdgesForMemory(gid)
if revived > 0 { changes.append("revived \(revived) edge(s)") }
}

Expand Down
23 changes: 14 additions & 9 deletions Sources/EngramKit/MemoryTools+Graph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,25 @@ extension MemoryTools {
/// tombstoned, so restoring one memory never resurrects links into
/// removed content.
@discardableResult
func reviveEdgesForMemory(_ gid: UUID) -> Int {
func reviveEdgesForMemory(_ gid: UUID) throws -> Int {
var total = 0
var lattices: [Lattice] = [localLattice]
if let syncedLattice { lattices.append(syncedLattice) }
for lattice in lattices {
let edges = lattice.objects(Edge.self)
.where { ($0.sourceGlobalId == gid || $0.targetGlobalId == gid) && $0.deletedAt != nil }
.snapshot()
for edge in edges {
let otherGid = edge.sourceGlobalId == gid ? edge.targetGlobalId : edge.sourceGlobalId
guard let (other, _) = findMemory(id: otherGid), other.deletedAt == nil else { continue }
edge.deletedAt = nil
total += 1
let revived = try lattice.withTransaction {
let edges = lattice.objects(Edge.self)
.where { ($0.sourceGlobalId == gid || $0.targetGlobalId == gid) && $0.deletedAt != nil }
.snapshot()
var count = 0
for edge in edges {
let otherGid = edge.sourceGlobalId == gid ? edge.targetGlobalId : edge.sourceGlobalId
guard let (other, _) = findMemory(id: otherGid), other.deletedAt == nil else { continue }
edge.deletedAt = nil
count += 1
}
return count
}
total += revived
}
return total
}
Expand Down
93 changes: 82 additions & 11 deletions Tests/EngramTests/TestHelpers.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Testing
import EngramKit
import EngramMemoryCore
import Lattice
import MCP
import Foundation
import SwiftLM

// Lattice isn't Sendable but tests are sequential — safe for test inspection.
extension Lattice: @retroactive @unchecked Sendable {}
Expand All @@ -21,15 +23,86 @@ let sharedEmbedder: EmbeddingService = {
return e
}()

private enum FixtureEmbeddingError: Error {
case invalidComputeUnits(String)
case missingResource(String)
case invalidDimension(Int)
case invalidVector
}

/// The same real MiniLM model, restricted to CPU execution for isolated runners.
private actor CPUFixtureEmbedder: Embedder {
private let model: CoreMLEmbeddingModel

init(model: CoreMLEmbeddingModel) { self.model = model }

var dimension: Int { model.embeddingDimension }

func embed(text: String) async throws -> [Float]? {
let vector = try await model.embed(text: text)
guard vector.count == 384, vector.allSatisfy({ $0.isFinite }) else {
throw FixtureEmbeddingError.invalidVector
}
return vector
}
}

// Global initialization is lazy; ordinary test runs keep using sharedEmbedder.
// One task shares successful loading or its error across concurrent fixtures.
private let cpuFixtureEmbedder = Task<CPUFixtureEmbedder, Error> {
let bundle: Bundle
if let path = ProcessInfo.processInfo.environment["ENGRAM_TEST_RESOURCE_BUNDLE"] {
guard let configured = Bundle(path: path) else {
throw FixtureEmbeddingError.missingResource(path)
}
bundle = configured
} else {
bundle = engramKitResourceBundle
}
guard let modelURL = bundle.url(forResource: "paraphrase-MiniLM-L6-v2_Embedding",
withExtension: "mlmodelc"),
let tokenizerURL = bundle.url(forResource: "paraphrase-MiniLM-L6-v2_tokenizer",
withExtension: nil) else {
throw FixtureEmbeddingError.missingResource("MiniLM model and tokenizer")
}
let model = try await CoreMLEmbeddingModel.loadCompiled(
url: modelURL, tokenizerDirectory: tokenizerURL, computeUnits: .cpuOnly)
guard model.embeddingDimension == 384 else {
throw FixtureEmbeddingError.invalidDimension(model.embeddingDimension)
}
return CPUFixtureEmbedder(model: model)
}

/// Opt into CPU-only real embeddings without changing production/default tests.
func loadedFixtureEmbedder() async throws -> any Embedder {
switch ProcessInfo.processInfo.environment["ENGRAM_TEST_COMPUTE_UNITS"] {
case "cpuOnly":
return try await cpuFixtureEmbedder.value
case nil, "":
if await !sharedEmbedder.isLoaded {
await sharedEmbedder.load()
}
return sharedEmbedder
case let value?:
throw FixtureEmbeddingError.invalidComputeUnits(value)
}
}

/// Allow isolated test runners to choose a writable fixture directory.
func testFixtureDirectory() throws -> URL {
let directory = ProcessInfo.processInfo.environment["ENGRAM_TEST_ROOT"]
.map { URL(fileURLWithPath: $0, isDirectory: true) }
?? FileManager.default.temporaryDirectory
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}

/// Create a MemoryTools with an isolated temp database and the real embedding model.
func makeTools() async throws -> MemoryTools {
let path = FileManager.default.temporaryDirectory
let path = try testFixtureDirectory()
.appending(path: "claude-memory-test-\(UUID().uuidString).sqlite")
let lattice = try Lattice(Memory.self, Edge.self, Checkpoint.self, HookState.self, SyncConfig.self, configuration: .init(fileURL: path))
let embedder = sharedEmbedder
if await !embedder.isLoaded {
await embedder.load()
}
let embedder = try await loadedFixtureEmbedder()
return MemoryTools(localRef: lattice.sendableReference, syncedRef: nil, embedder: embedder)
}

Expand All @@ -42,18 +115,16 @@ struct DualDBContext {

/// Create a MemoryTools with separate local and synced databases for testing dual-DB routing.
func makeDualDBTools() async throws -> DualDBContext {
let localPath = FileManager.default.temporaryDirectory
let directory = try testFixtureDirectory()
let localPath = directory
.appending(path: "claude-memory-test-local-\(UUID().uuidString).sqlite")
let syncedPath = FileManager.default.temporaryDirectory
let syncedPath = directory
.appending(path: "claude-memory-test-synced-\(UUID().uuidString).sqlite")

let localLattice = try Lattice(Memory.self, Edge.self, Checkpoint.self, HookState.self, SyncConfig.self, configuration: .init(fileURL: localPath))
let syncedLattice = try Lattice(Memory.self, Edge.self, SyncConfig.self, configuration: .init(fileURL: syncedPath))

let embedder = sharedEmbedder
if await !embedder.isLoaded {
await embedder.load()
}
let embedder = try await loadedFixtureEmbedder()

let tools = MemoryTools(
localRef: localLattice.sendableReference,
Expand Down
Loading
Loading