Files
Jarvis/App/MCP/InProcessMemoryAdapters.swift
james.maesandClaude Opus 4.7 cb584a2ac8 feat(d5d6): real memory database — vec0 statically linked, hard-fail boot, stats tool
Closes D-5 / D-6 (sqlite-vec dylib bundling) carry-forwards from the
2026-05-04 audit by adopting jkrukowski/SQLiteVec as a SPM dependency.
The package's CSQLiteVec target compiles sqlite3.c + sqlite-vec.c into
one statically-linked C target with `core_vec_init()` registering vec0
as an auto-extension. All sqlite3_* calls in Replay's SQLiteConnection
and Memory's MemoryStore resolve to this bundled SQLite (vec0 always
available) instead of Apple's stripped /usr/lib/libsqlite3.dylib.

The retired hand-rolled approach (dlsym pathway + JARVIS_VEC0_STUB_PATH
env-flag + Bundle.module placeholder text file) never worked on macOS
because Apple stripped sqlite3_load_extension from the system libsqlite3.
Memory init has been silently dormant since Phase 7 (2026-04 onward),
which is what made B-02 (history threading) appear permanently broken
to the user during dogfooding even after the 48ad8d6 tactical patch
landed.

Live verification 2026-05-11: fresh launch with empty jarvis.db creates
schema, persists turn rows across submits, B-02 multi-turn context
resolves correctly ("what breed is Toby?" → "Golden Retriever" after
prior turn established Toby is a Golden Retriever), and the new
get_memory_stats tool reports `turnsCount=6, vecVersion=v0.1.6,
sqliteVersion=3.51.0`.

Changes:

- packages/Replay/Package.swift
    + SQLiteVec SPM dependency (from: "0.0.14")
    + CSQLiteVec product wired into Replay target
- packages/Replay/Sources/Replay/SQLiteConnection.swift
    - import SQLite3
    + @_exported import CSQLiteVec
    All sqlite3_* call sites unchanged — linker now resolves to our
    bundled sqlite3 with vec0 statically linked in.
- packages/Memory/Package.swift
    + SQLiteVec SPM dependency (named product so MemoryStore can import
      CSQLiteVec directly for the core_vec_init() one-shot call)
    - resources: PLACEHOLDER.txt copy (defunct vec0.dylib stub slot)
- packages/Memory/Sources/Memory/MemoryStore.swift
    - import SQLite3 → import CSQLiteVec
    + _vecAutoExtensionRegistered static-let one-shot: calls
      CSQLiteVec.core_vec_init() which registers vec0 via
      sqlite3_auto_extension on the first MemoryStore construction.
      Idempotent (static-let initializer fires exactly once).
    - loadVecExtension(on:) dlsym/JARVIS_VEC0_STUB_PATH/Bundle.module
      pathway (retired; ~70 lines removed)
    Updated docstring to reflect the new hard-fail boot policy.
- packages/Memory/Sources/Memory/Resources/PLACEHOLDER.txt — deleted
- App/AppDelegate.swift installMemory()
    Reverses Track-D D-1 (75a10be, 2026-05-04): MemoryStore.init failures
    are now FATAL via Logger.critical + TCCAlertService.presentHardBlock
    + NSApp.terminate. The graceful-degradation path that kept the app
    booting with memoryStore=nil hid the entire B-02/B-04/B-05 silent-
    failure bug class. Aligns with the 2026-05-07 API-first pivot
    thesis: "no silent failures".
    `store` is now non-optional throughout installMemory; downstream
    callers no longer need `if let store = store`.
- packages/MCP/Sources/MCP/InProcess/GetMemoryStatsTool.swift (new)
    + MemoryStatsDispatching protocol
    + MemoryStats Codable struct (turnsCount, factsTotal, factsActive,
      sessionsCount, dbFileBytes, vecVersion, sqliteVersion,
      lastTurnAtUnixMs, lastFactAtUnixMs)
    + GetMemoryStatsTool conforming to InProcessTool
- App/MCP/InProcessMemoryAdapters.swift
    + MemoryStatsStoreAdapter — bridges the protocol to MemoryStore's
      public queryRowCount / querySingleString seams and FileManager
      for the on-disk file size. Read-only, no confirmation.
- App/AppDelegate.swift installSelfKnowledgeTools()
    + Registers GetMemoryStatsTool with MemoryStatsStoreAdapter.
- packages/Memory/Tests/MemoryTests/*
    Retired the JARVIS_VEC0_STUB_PATH XCTSkipUnless guards from 9 tests
    (vec0 is always available now — no need to env-gate). Rewrote
    MemoryStoreTests to drop the now-impossible "init throws when vec
    missing" case and assert facts_vec virtual table creation +
    vec_version() readback. Added seedTurnRow helper to
    MemoryStoreApplyOpTests so the now-enforced
    `facts.source_turn_id REFERENCES turns(id)` foreign key is honored
    in tests that previously passed when FK enforcement was dormant.

Test summary: Memory full suite 91 tests, 10 skipped (env-gated real-
Ollama tests), 0 failures. App target builds clean. Substrate verified
end-to-end via live launch (vec_version: v0.1.6 logged at boot;
get_memory_stats returns actual schema row counts).

Pending D-7 carry-forward: ollama pull nomic-embed-text (~270MB) for
embedder; user already has qwen2.5-coder:32b-instruct-q8_0 which is
a higher-quality variant of the project's hardcoded
qwen2.5-coder:32b — small ModelID config delta to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:25:56 -05:00

125 lines
5.1 KiB
Swift

// InProcessMemoryAdapters.swift
//
// Track-D D-2: bridges MCP's tool dispatch protocols
// (`HybridSearchDispatching`, `ForgetFactDispatching`) to the Memory module's
// concrete actors. These adapters live in App/ rather than packages/MCP/
// because they cross the module boundary — JarvisMCP intentionally does NOT
// import Memory (Plan 07-03 design: protocol seam keeps MCP unaware of
// SQLite/HybridSearch internals).
//
// Both adapters are `Sendable` value types with an internal actor reference;
// conformance methods hop into the actor and translate Memory's return types
// to MCP's tool-boundary types so MCP doesn't have to import Memory.
import Foundation
import JarvisMCP
import Memory
/// Bridges `SearchMemoryTool`'s `HybridSearchDispatching` protocol to a
/// concrete `Memory.HybridSearch` actor. Translates `[Memory.FactRef]` rows
/// into `[SearchMemoryHit]` so the MCP module doesn't depend on Memory's
/// FactRef type.
public struct HybridSearchAdapter: HybridSearchDispatching {
private let hybrid: HybridSearch
public init(hybrid: HybridSearch) {
self.hybrid = hybrid
}
public func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] {
let refs = try await hybrid.searchFacts(query: query, k: k, triggerTurnId: triggerTurnId)
return refs.map { ref in
SearchMemoryHit(
factId: ref.factId,
summary: ref.summary,
score: ref.score,
triggerTurnId: ref.triggerTurnId
)
}
}
}
/// Bridges `ForgetFactTool`'s `ForgetFactDispatching` protocol to
/// `MemoryStore.forgetFact`. The store actor's `forgetFact` is a synchronous
/// throwing method (it wraps an internal SQLite transaction); the adapter
/// hops into the actor and forwards.
public struct ForgetFactStoreAdapter: ForgetFactDispatching {
private let store: MemoryStore
public init(store: MemoryStore) {
self.store = store
}
public func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool {
try await store.forgetFact(id: id, triggerTurnId: triggerTurnId)
}
}
/// D-5/D-6 closure follow-up: bridges `GetMemoryStatsTool`'s
/// `MemoryStatsDispatching` protocol to `MemoryStore`. Reads counts
/// + size via the store's public `queryRowCount` / `querySingleString`
/// seams and `FileManager` for the on-disk file size. The DB URL must
/// be passed at construction since `MemoryStore` doesn't expose it
/// publicly.
public struct MemoryStatsStoreAdapter: MemoryStatsDispatching {
private let store: MemoryStore
private let databaseURL: URL
public init(store: MemoryStore, databaseURL: URL) {
self.store = store
self.databaseURL = databaseURL
}
public func getMemoryStats() async throws -> MemoryStats {
let turnsCount = try await store.queryRowCount("SELECT id FROM turns")
let factsTotal = try await store.queryRowCount("SELECT id FROM facts")
let factsActive = try await store.queryRowCount(
"SELECT id FROM facts WHERE valid_to IS NULL AND forgotten_at IS NULL"
)
let sessionsCount = try await store.queryRowCount(
"SELECT DISTINCT session_id FROM turns"
)
let vecVersion = (try await store.querySingleString("SELECT vec_version()")) ?? "unknown"
let sqliteVersion = (try await store.querySingleString("SELECT sqlite_version()")) ?? "unknown"
// Max created_at; nil when the corresponding table is empty.
let lastTurnAt = try await maxCreatedAt(from: "turns")
let lastFactAt = try await maxCreatedAt(from: "facts")
// File size — kernel view of jarvis.db; WAL/SHM sidecars not included.
let dbFileBytes: Int64
if let attrs = try? FileManager.default.attributesOfItem(atPath: databaseURL.path),
let size = attrs[.size] as? Int64 {
dbFileBytes = size
} else if let attrs = try? FileManager.default.attributesOfItem(atPath: databaseURL.path),
let size = attrs[.size] as? Int {
dbFileBytes = Int64(size)
} else {
dbFileBytes = 0
}
return MemoryStats(
turnsCount: turnsCount,
factsTotal: factsTotal,
factsActive: factsActive,
sessionsCount: sessionsCount,
dbFileBytes: dbFileBytes,
vecVersion: vecVersion,
sqliteVersion: sqliteVersion,
lastTurnAtUnixMs: lastTurnAt,
lastFactAtUnixMs: lastFactAt
)
}
/// Returns max created_at as Int64 unix-ms, or nil if the table is empty.
/// SQLite's MAX over an empty set returns NULL → querySingleString returns
/// nil; we forward the nil rather than masking it as 0.
private func maxCreatedAt(from table: String) async throws -> Int64? {
// Whitelist table name to defend against future call-site changes.
guard table == "turns" || table == "facts" else { return nil }
let s = try await store.querySingleString("SELECT MAX(created_at) FROM \(table)")
guard let s, let n = Int64(s) else { return nil }
return n
}
}