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
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,13 @@ jobs:
# tension recall assertions from the revival. ~345 correctness tests
# remain as the release bar.
- name: Run tests
timeout-minutes: 30
id: native_tests
# Keep the full 30-minute test budget, plus bounded diagnostics/cleanup.
timeout-minutes: 32
run: >
python3 -B scripts/run_native_tests.py
--diagnostics-dir build/native-test-diagnostics
--timeout-seconds 1800 --silence-seconds 300 --
swift test --force-resolved-versions --skip-build --filter "EngramTests|EngramMemoryCoreTests|EngramRealityKitTests|PositionVersionTests"
--skip "PerfTests"
--skip "keyBERTKeywordExtraction"
Expand All @@ -139,6 +144,15 @@ jobs:
--skip "recall_statementBudget"
--skip "clusters_statementBudget"

- name: Upload native test diagnostics
if: ${{ (failure() || cancelled()) && steps.native_tests.outcome != 'skipped' }}
uses: actions/upload-artifact@v4
with:
name: native-test-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: build/native-test-diagnostics/
if-no-files-found: ignore
retention-days: 7

# Statement-budget regressions read the process-global SQL statement
# counter; suites running in parallel pollute every measurement window
# (sustained, not bursty — min-of-N can't rescue it). They run alone in
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ All notable changes to Engram are documented in this file.
failure is reported instead of continuing after a failed transaction start.
- Include the actual advice query as JSON-quoted provenance, preventing a
multiline query from introducing extra result headings.
- Keep advice within its overall character budget, including query provenance,
headings, and truncation markers, and report only memory IDs retained in it.
- Persist project changes made through the typed Swift memory service, including
project-only updates and updates that also edit a topic.
- Preserve plugin-owned Codex configuration during native app/CLI installation;
Expand Down
57 changes: 42 additions & 15 deletions Sources/EngramKit/MemoryTools+Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,27 @@ extension MemoryTools {

// MARK: - recall

/// Capture provenance while rendering, never by parsing recalled content.
nonisolated static func appendRecallRowMarker(_ id: UUID, to output: inout String,
rows: inout [RecallRowBoundary]) {
output += "[id:\(id.uuidString)] "
rows.append(RecallRowBoundary(id: id, markerEnd: output.count))
}

func handleRecall(_ args: [String: Value]?) async throws -> CallTool.Result {
lastRecallHits = []
lastRecallMode = .vector
lastRecallRows = []
var recalledHits: [RecallHit] = []
var recalledMode: RecallMode = .vector
var renderedRows: [RecallRowBoundary] = []
// Keep capture local across embedding awaits, then publish it with
// this invocation's result, including empty/error returns.
defer {
lastRecallHits = recalledHits
lastRecallMode = recalledMode
lastRecallRows = renderedRows
}
let a = try args.decode(RecallArgs.self)
guard !a.query.isEmpty else {
throw MCPError.invalidParams("'query' is required")
Expand Down Expand Up @@ -556,7 +574,7 @@ extension MemoryTools {
// access-stat bump — the bump's writes invalidate the row caches,
// so a post-bump read would re-issue one SELECT per field.
// Traversal hits append below.
lastRecallHits = filtered.compactMap { hit in
recalledHits = filtered.compactMap { hit in
guard hit.object.globalId != nil else { return nil }
return RecallHit(memory: record(from: hit.object),
distance: hit.distance,
Expand All @@ -583,7 +601,7 @@ extension MemoryTools {
return isHubResident(gid)
}

let lines = filtered.compactMap { match -> String? in
let lines = filtered.compactMap { match -> (id: UUID, body: String)? in
let m = match.object
guard let mGid = m.globalId else { return nil }
let dist = String(format: "%.3f", match.distance)
Expand All @@ -609,15 +627,20 @@ extension MemoryTools {
// escape-hardened indentation fence.
let body = (isForeign && fenceForeignContent)
? Self.fencedForeignContent(m.content) : m.content
return "[id:\(mGid.uuidString)] [\(m.project)/\(m.topic)]\(badge)\(via) (distance: \(dist)\(impInfo)\(expires)\(created)) \(body)"
return (mGid, "[\(m.project)/\(m.topic)]\(badge)\(via) (distance: \(dist)\(impInfo)\(expires)\(created)) \(body)")
}

var output = lines.joined(separator: "\n\n")
var output = ""

// Knowledge gap detection — signal when recall results are weak
let avgDistance = filtered.map(\.distance).reduce(0, +) / Double(max(filtered.count, 1))
if avgDistance > 1.05 { // v2: relevant query→memory hits measure 0.86–1.05; beyond = weak
output = "⚠️ Weak recall (avg distance: \(String(format: "%.3f", avgDistance)), count: \(filtered.count)). Results may not be closely related to the query.\n\n" + output
output = "⚠️ Weak recall (avg distance: \(String(format: "%.3f", avgDistance)), count: \(filtered.count)). Results may not be closely related to the query.\n\n"
}
for (index, line) in lines.enumerated() {
if index > 0 { output += "\n\n" }
Self.appendRecallRowMarker(line.id, to: &output, rows: &renderedRows)
output += line.body
}

log("[recall] Output formatted, \(lines.count) lines")
Expand Down Expand Up @@ -679,7 +702,7 @@ extension MemoryTools {
for mem in connected {
let m = mem.memory
guard let memGlobalId = m.globalId else { continue }
lastRecallHits.append(RecallHit(memory: record(from: m),
recalledHits.append(RecallHit(memory: record(from: m),
distance: 0,
depth: mem.depth,
isForeign: isForeignAuthored(m)))
Expand All @@ -699,17 +722,19 @@ extension MemoryTools {
? " [by:\(GroupDirectory.badgeName(for: m.authorUserId))]" : ""
let connVia = viaMarker(for: memGlobalId)
// Small memories shown in full; large ones get a compact preview
output += "\n\n"
Self.appendRecallRowMarker(memGlobalId, to: &output, rows: &renderedRows)
if isForeignAuthored(m) && fenceForeignContent {
output += "\n\n[id:\(memGlobalId.uuidString)] [\(m.project)/\(m.topic)]\(connBadge)\(connVia)\(expires)\(edgeInfo) \(Self.fencedForeignContent(m.content))"
output += "[\(m.project)/\(m.topic)]\(connBadge)\(connVia)\(expires)\(edgeInfo) \(Self.fencedForeignContent(m.content))"
} else if m.content.count <= 500 {
output += "\n\n[id:\(memGlobalId.uuidString)] [\(m.project)/\(m.topic)]\(connBadge)\(connVia)\(expires)\(edgeInfo) \(m.content)"
output += "[\(m.project)/\(m.topic)]\(connBadge)\(connVia)\(expires)\(edgeInfo) \(m.content)"
} else {
let firstLine = m.content.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? m.content
let preview = String(firstLine.prefix(120))
let charCount = m.content.count
let sectionCount = m.content.components(separatedBy: "\n").filter { $0.hasPrefix("## ") || $0.hasPrefix("### ") }.count
let sizeInfo = sectionCount > 0 ? "\(sectionCount) sections, \(charCount) chars" : "\(charCount) chars"
output += "\n\n[id:\(memGlobalId.uuidString)] [\(m.project)/\(m.topic)]\(connBadge)\(connVia) (\(sizeInfo)\(expires))\(edgeInfo) \(preview)\(charCount > 120 ? "..." : "")"
output += "[\(m.project)/\(m.topic)]\(connBadge)\(connVia) (\(sizeInfo)\(expires))\(edgeInfo) \(preview)\(charCount > 120 ? "..." : "")"
}
}
}
Expand Down Expand Up @@ -740,7 +765,7 @@ extension MemoryTools {
sessionLog("[recall] DONE, returning \(output.count) chars")
return CallTool.Result(content: [.text(output)], isError: false)
} else {
lastRecallMode = .fullText
recalledMode = .fullText
log("[recall] No embedding available, falling back to FTS5")
// Degraded mode: FTS5 full-text search (no embedding model loaded)
let contentWords = Self.extractContentWords(from: query)
Expand All @@ -763,7 +788,7 @@ extension MemoryTools {
}
let ftsResults = results.matching(ftsQuery, on: \.content, limit: limit)

var lines: [String] = []
var output = ""
for match in ftsResults {
let m = match.object
m.materialize() // hydrated by the FTS query — format for free
Expand All @@ -773,7 +798,7 @@ extension MemoryTools {
let expires = m.expiresAt == .distantFuture ? "" : ", expires: \(Self.dateFormatter.string(from: m.expiresAt))"
let created = hasTemporalFilter ? ", created: \(Self.dateFormatter.string(from: m.createdAt))" : ""
guard let mGid = m.globalId else { continue }
lastRecallHits.append(RecallHit(memory: record(from: m),
recalledHits.append(RecallHit(memory: record(from: m),
distance: 0,
depth: 0,
isForeign: isForeign))
Expand All @@ -782,12 +807,14 @@ extension MemoryTools {
let via = viaMarker(for: mGid)
let body = (isForeign && fenceForeignContent)
? Self.fencedForeignContent(m.content) : m.content
lines.append("[id:\(mGid.uuidString)] [\(m.project)/\(m.topic)]\(badge)\(via)\(ftsInfo)\(expires)\(created) \(body)")
if !output.isEmpty { output += "\n\n" }
Self.appendRecallRowMarker(mGid, to: &output, rows: &renderedRows)
output += "[\(m.project)/\(m.topic)]\(badge)\(via)\(ftsInfo)\(expires)\(created) \(body)"
}
if lines.isEmpty {
if output.isEmpty {
return CallTool.Result(content: [.text("No memories found.")], isError: false)
}
return CallTool.Result(content: [.text(lines.joined(separator: "\n\n"))], isError: false)
return CallTool.Result(content: [.text(output)], isError: false)
}
}

Expand Down
45 changes: 38 additions & 7 deletions Sources/EngramKit/MemoryTools+Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,24 @@ extension MemoryTools: MemoryService {
// MARK: Core reads

public func recall(_ request: RecallRequest) async throws -> RecallResult {
let captured = try await recallWithRows(request)
return captured.result
}

private func recallWithRows(_ request: RecallRequest) async throws
-> (result: RecallResult, rows: [RecallRowBoundary]) {
var args: [String: Value] = [
"query": .string(request.query),
"depth": .int(request.depth),
"limit": .int(request.limit),
]
if let project = request.project { args["project"] = .string(project) }
let result = try await handleRecall(args)
return RecallResult(hits: lastRecallHits,
mode: lastRecallMode,
renderedText: Self.text(from: result))
// Snapshot both captures together before returning across another
// await; advice must never consult a later call's actor state.
return (RecallResult(hits: lastRecallHits,
mode: lastRecallMode,
renderedText: Self.text(from: result)), lastRecallRows)
}

public func advise(_ request: AdviseRequest) async throws -> AdviseResult {
Expand All @@ -44,19 +52,42 @@ extension MemoryTools: MemoryService {
// analytics loop is the tuner (plan §advise).
let words = MemoryTools.extractContentWords(from: request.prompt)
let query = words.isEmpty ? request.prompt : words.joined(separator: " ")
let recallResult = try await recall(RecallRequest(
let captured = try await recallWithRows(RecallRequest(
query: query, project: request.project, depth: 1, limit: 5))
return Self.boundedAdvice(captured.result, rows: captured.rows,
query: query, budget: request.budget)
}

/// Keep the exact query provenance and already-fenced recall prefix inside
/// the overall block budget. Partial or omitted row IDs are not feedback.
nonisolated static func boundedAdvice(_ recallResult: RecallResult,
rows: [RecallRowBoundary],
query: String, budget: Int) -> AdviseResult {
guard !recallResult.hits.isEmpty,
recallResult.renderedText != "No memories found." else {
return AdviseResult(block: nil, memoryIds: [], mode: recallResult.mode)
}
let budget = max(0, budget)
let overhead = AdviseAssembly.memorySection(renderedRecall: "", query: query).count
guard budget > overhead else {
return AdviseResult(block: nil, memoryIds: [], mode: recallResult.mode)
}
let available = budget - overhead
var rendered = recallResult.renderedText
if rendered.count > request.budget {
rendered = String(rendered.prefix(request.budget)) + "\n… (truncated)"
var retainedCharacters = rendered.count
if rendered.count > available {
let suffix = "\n… (truncated)"
let retained = String(rendered.prefix(max(0, available - suffix.count)))
retainedCharacters = retained.count
rendered = retained + String(suffix.prefix(available))
}
let visibleIds = rows.filter { $0.markerEnd <= retainedCharacters }.map(\.id)
guard !visibleIds.isEmpty else {
return AdviseResult(block: nil, memoryIds: [], mode: recallResult.mode)
}
return AdviseResult(
block: AdviseAssembly.memorySection(renderedRecall: rendered, query: query),
memoryIds: recallResult.hits.map(\.memory.id),
memoryIds: visibleIds,
mode: recallResult.mode)
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/EngramKit/MemoryTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public actor MemoryTools {
/// write and the read, so an interleaved recall cannot cross-wire it.
var lastRecallHits: [RecallHit] = []
var lastRecallMode: RecallMode = .vector
struct RecallRowBoundary: Sendable {
let id: UUID
/// Character offset immediately after the renderer-owned row marker.
let markerEnd: Int
}
var lastRecallRows: [RecallRowBoundary] = []

/// The globalId of the last remembered row — same capture contract.
var lastRememberedId: UUID?
Expand Down
Loading
Loading