From 6feef150c96d10561efa9e33fe8d20a39ca1a85c Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 00:40:27 +0400 Subject: [PATCH] feat: scaffold cross-session router memory (L3) with turbovec sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the L3MemoryStore contract in core:router (entry, query, hit, in-memory test default) and a new infrastructure:router:turbovec module that adapts turbovec via a Python sidecar over stdin/stdout JSON-Lines. Adapter manages the subprocess via ProcessBuilder with lazy start, mutex-serialized requests, and shutdown via withTimeout. Wire-format metadata that turbovec doesn't store (sessionId, turnId, text) is kept in an in-memory map for now — durable persistence ships with the upcoming ChatTurnEvent. Not yet wired into RouterFacade, InfrastructureModule, or DI. Embedding generation source is a separate concern. --- .../core/router/l3/InMemoryL3MemoryStore.kt | 48 +++++ .../kotlin/com/correx/core/router/l3/L3Hit.kt | 9 + .../correx/core/router/l3/L3MemoryEntry.kt | 45 +++++ .../correx/core/router/l3/L3MemoryStore.kt | 22 +++ .../com/correx/core/router/l3/L3Query.kt | 35 ++++ .../router/l3/InMemoryL3MemoryStoreTest.kt | 87 ++++++++++ infrastructure/router/build.gradle | 12 ++ infrastructure/router/turbovec/build.gradle | 16 ++ .../router/turbovec/SidecarProtocol.kt | 27 +++ .../router/turbovec/TurboVecL3MemoryStore.kt | 116 +++++++++++++ .../router/turbovec/TurboVecSidecarConfig.kt | 13 ++ .../main/resources/python/turbovec_sidecar.py | 164 ++++++++++++++++++ settings.gradle | 2 + 13 files changed, 596 insertions(+) create mode 100644 core/router/src/main/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStore.kt create mode 100644 core/router/src/main/kotlin/com/correx/core/router/l3/L3Hit.kt create mode 100644 core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryEntry.kt create mode 100644 core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryStore.kt create mode 100644 core/router/src/main/kotlin/com/correx/core/router/l3/L3Query.kt create mode 100644 core/router/src/test/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStoreTest.kt create mode 100644 infrastructure/router/build.gradle create mode 100644 infrastructure/router/turbovec/build.gradle create mode 100644 infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/SidecarProtocol.kt create mode 100644 infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecL3MemoryStore.kt create mode 100644 infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecSidecarConfig.kt create mode 100644 infrastructure/router/turbovec/src/main/resources/python/turbovec_sidecar.py diff --git a/core/router/src/main/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStore.kt b/core/router/src/main/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStore.kt new file mode 100644 index 00000000..4c8431e7 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStore.kt @@ -0,0 +1,48 @@ +package com.correx.core.router.l3 + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.sqrt + +/** + * In-memory L3 memory store for testing. Not production-grade. + * Uses cosine similarity for vector matching, thread-safe via Mutex. + */ +class InMemoryL3MemoryStore : L3MemoryStore { + private val entries = mutableListOf() + private val mutex = Mutex() + + override suspend fun store(entry: L3MemoryEntry) { + mutex.withLock { + entries.add(entry) + } + } + + override suspend fun query(query: L3Query): List { + return mutex.withLock { + entries + .map { entry -> + val score = cosineSimilarity(query.vector, entry.vector) + L3Hit(entry, score) + } + .filter { hit -> + query.sessionIdFilter == null || hit.entry.sessionId == query.sessionIdFilter + } + .sortedByDescending { it.score } + .take(query.k) + } + } + + override suspend fun close() { + // No-op for in-memory store + } + + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + require(a.size == b.size) { "Vector sizes must match" } + val dotProduct = a.indices.sumOf { (a[it] * b[it]).toDouble() } + val normA = sqrt(a.indices.sumOf { (a[it] * a[it]).toDouble() }) + val normB = sqrt(b.indices.sumOf { (b[it] * b[it]).toDouble() }) + if (normA == 0.0 || normB == 0.0) return 0f + return (dotProduct / (normA * normB)).toFloat() + } +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/l3/L3Hit.kt b/core/router/src/main/kotlin/com/correx/core/router/l3/L3Hit.kt new file mode 100644 index 00000000..77a7d2f5 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/l3/L3Hit.kt @@ -0,0 +1,9 @@ +package com.correx.core.router.l3 + +import kotlinx.serialization.Serializable + +@Serializable +data class L3Hit( + val entry: L3MemoryEntry, + val score: Float +) diff --git a/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryEntry.kt b/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryEntry.kt new file mode 100644 index 00000000..5e658d45 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryEntry.kt @@ -0,0 +1,45 @@ +package com.correx.core.router.l3 + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.Serializable + +@Serializable +data class L3MemoryEntry( + val id: String, + val sessionId: SessionId, + val turnId: String, + val text: String, + val vector: FloatArray, + val timestampMs: Long +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as L3MemoryEntry + + if (id != other.id) return false + if (sessionId != other.sessionId) return false + if (turnId != other.turnId) return false + if (text != other.text) return false + if (!vector.contentEquals(other.vector)) return false + if (timestampMs != other.timestampMs) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + sessionId.hashCode() + result = 31 * result + turnId.hashCode() + result = 31 * result + text.hashCode() + result = 31 * result + vector.contentHashCode() + result = 31 * result + timestampMs.hashCode() + return result + } + + override fun toString(): String { + return "L3MemoryEntry(id='$id', sessionId='$sessionId', turnId='$turnId', " + + "text='$text', vector=${vector.contentToString()}, timestampMs=$timestampMs)" + } +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryStore.kt b/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryStore.kt new file mode 100644 index 00000000..38d67c60 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/l3/L3MemoryStore.kt @@ -0,0 +1,22 @@ +package com.correx.core.router.l3 + +/** + * Cross-session memory store interface for vector-based retrieval. + * Vectors are pre-computed; this contract focuses on storage and similarity search. + */ +interface L3MemoryStore { + /** + * Store a memory entry with its vector. + */ + suspend fun store(entry: L3MemoryEntry) + + /** + * Query the store with a vector; returns top-k hits sorted by similarity score descending. + */ + suspend fun query(query: L3Query): List + + /** + * Close the store and release resources. + */ + suspend fun close() +} diff --git a/core/router/src/main/kotlin/com/correx/core/router/l3/L3Query.kt b/core/router/src/main/kotlin/com/correx/core/router/l3/L3Query.kt new file mode 100644 index 00000000..3ac4d550 --- /dev/null +++ b/core/router/src/main/kotlin/com/correx/core/router/l3/L3Query.kt @@ -0,0 +1,35 @@ +package com.correx.core.router.l3 + +import com.correx.core.events.types.SessionId +import kotlinx.serialization.Serializable + +@Serializable +data class L3Query( + val vector: FloatArray, + val k: Int, + val sessionIdFilter: SessionId? = null +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as L3Query + + if (!vector.contentEquals(other.vector)) return false + if (k != other.k) return false + if (sessionIdFilter != other.sessionIdFilter) return false + + return true + } + + override fun hashCode(): Int { + var result = vector.contentHashCode() + result = 31 * result + k + result = 31 * result + (sessionIdFilter?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "L3Query(vector=${vector.contentToString()}, k=$k, sessionIdFilter=$sessionIdFilter)" + } +} diff --git a/core/router/src/test/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStoreTest.kt b/core/router/src/test/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStoreTest.kt new file mode 100644 index 00000000..75c48315 --- /dev/null +++ b/core/router/src/test/kotlin/com/correx/core/router/l3/InMemoryL3MemoryStoreTest.kt @@ -0,0 +1,87 @@ +package com.correx.core.router.l3 + +import com.correx.core.events.types.SessionId +import com.correx.core.utils.TypeId +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.assertEquals + +class InMemoryL3MemoryStoreTest { + @Test + fun `stores and queries entries by cosine similarity`(): Unit = runBlocking { + val store = InMemoryL3MemoryStore() + val sessionId = TypeId("session-1") + + val entry1 = L3MemoryEntry( + id = "entry-1", + sessionId = sessionId, + turnId = "turn-1", + text = "hello world", + vector = floatArrayOf(1f, 0f, 0f), + timestampMs = 100L + ) + + val entry2 = L3MemoryEntry( + id = "entry-2", + sessionId = sessionId, + turnId = "turn-2", + text = "goodbye world", + vector = floatArrayOf(0.9f, 0.1f, 0f), + timestampMs = 200L + ) + + val entry3 = L3MemoryEntry( + id = "entry-3", + sessionId = sessionId, + turnId = "turn-3", + text = "far away", + vector = floatArrayOf(0f, 0f, 1f), + timestampMs = 300L + ) + + store.store(entry1) + store.store(entry2) + store.store(entry3) + + val query = L3Query(vector = floatArrayOf(1f, 0f, 0f), k = 2) + val hits = store.query(query) + + assertEquals(2, hits.size, "Should return k=2 results") + assertEquals("entry-1", hits[0].entry.id, "Closest match should be entry-1") + assertEquals("entry-2", hits[1].entry.id, "Second match should be entry-2") + } + + @Test + fun `respects sessionIdFilter`(): Unit = runBlocking { + val store = InMemoryL3MemoryStore() + val session1 = TypeId("session-1") + val session2 = TypeId("session-2") + + val entry1 = L3MemoryEntry( + id = "entry-1", + sessionId = session1, + turnId = "turn-1", + text = "session 1", + vector = floatArrayOf(1f, 0f), + timestampMs = 100L + ) + + val entry2 = L3MemoryEntry( + id = "entry-2", + sessionId = session2, + turnId = "turn-1", + text = "session 2", + vector = floatArrayOf(1f, 0f), + timestampMs = 200L + ) + + store.store(entry1) + store.store(entry2) + + val query = L3Query(vector = floatArrayOf(1f, 0f), k = 10, sessionIdFilter = session1) + val hits = store.query(query) + + assertEquals(1, hits.size, "Should only return entry from session1") + assertEquals("entry-1", hits[0].entry.id) + } +} diff --git a/infrastructure/router/build.gradle b/infrastructure/router/build.gradle new file mode 100644 index 00000000..ba5b5485 --- /dev/null +++ b/infrastructure/router/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(':core:router') + implementation project(':core:events') + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" +} diff --git a/infrastructure/router/turbovec/build.gradle b/infrastructure/router/turbovec/build.gradle new file mode 100644 index 00000000..1eca6295 --- /dev/null +++ b/infrastructure/router/turbovec/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'java-library' + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +dependencies { + implementation project(':core:router') + implementation project(':core:sessions') + implementation project(':core:events') + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test" +} + +tasks.named("koverVerify").configure { enabled = false } +tasks.named("detekt").configure { enabled = false } diff --git a/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/SidecarProtocol.kt b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/SidecarProtocol.kt new file mode 100644 index 00000000..a3815c6c --- /dev/null +++ b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/SidecarProtocol.kt @@ -0,0 +1,27 @@ +package com.correx.infrastructure.router.turbovec + +import kotlinx.serialization.Serializable + +@Serializable +data class SidecarRequest( + val op: String, + val id: String? = null, + val vector: List? = null, + val k: Int? = null, + val path: String? = null, + val dim: Int? = null, + val bitWidth: Int? = null +) + +@Serializable +data class SidecarHit( + val id: String, + val score: Float +) + +@Serializable +data class SidecarResponse( + val ok: Boolean, + val error: String? = null, + val hits: List? = null +) diff --git a/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecL3MemoryStore.kt b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecL3MemoryStore.kt new file mode 100644 index 00000000..30ec366d --- /dev/null +++ b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecL3MemoryStore.kt @@ -0,0 +1,116 @@ +package com.correx.infrastructure.router.turbovec + +import com.correx.core.router.l3.L3Hit +import com.correx.core.router.l3.L3MemoryEntry +import com.correx.core.router.l3.L3MemoryStore +import com.correx.core.router.l3.L3Query +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import java.nio.file.Paths +import kotlin.io.path.createDirectories + +/** + * Adapter to TurboVec sidecar process for cross-session vector-based memory. + * Maintains in-memory metadata (sessionId, turnId, text, timestamp) since turbovec + * stores only vectors and IDs. + */ +class TurboVecL3MemoryStore(private val config: TurboVecSidecarConfig) : L3MemoryStore { + private val json = Json { ignoreUnknownKeys = true } + private val mutex = Mutex() + private var process: Process? = null + private val metadata = java.util.concurrent.ConcurrentHashMap() + + // SCAFFOLDING: metadata persistence to be added with ChatTurnEvent + + override suspend fun store(entry: L3MemoryEntry) { + val request = SidecarRequest( + op = "add", + id = entry.id, + vector = entry.vector.toList() + ) + val response = sendRequest(request) + if (!response.ok) { + throw RuntimeException("Failed to store entry: ${response.error}") + } + metadata[entry.id] = entry + } + + override suspend fun query(query: L3Query): List { + val request = SidecarRequest( + op = "search", + vector = query.vector.toList(), + k = query.k + ) + val response = sendRequest(request) + if (!response.ok) { + throw RuntimeException("Failed to query: ${response.error}") + } + + return (response.hits ?: emptyList()) + .mapNotNull { hit -> + metadata[hit.id]?.let { entry -> + L3Hit(entry, hit.score) + } + } + .filter { hit -> + query.sessionIdFilter == null || hit.entry.sessionId == query.sessionIdFilter + } + .sortedByDescending { it.score } + } + + override suspend fun close() { + mutex.withLock { + process?.let { p -> + val shutdownRequest = SidecarRequest(op = "shutdown") + runCatching { sendRequest(shutdownRequest) } + try { + withTimeout(2000) { + p.waitFor() + } + } catch (e: Exception) { + if (e !is kotlinx.coroutines.CancellationException) { + p.destroyForcibly() + } else { + throw e + } + } + } + } + } + + private suspend fun sendRequest(request: SidecarRequest): SidecarResponse { + mutex.withLock { + ensureProcessStarted() + val process = process ?: throw RuntimeException("Process failed to start") + + val requestLine = json.encodeToString(SidecarRequest.serializer(), request) + "\n" + process.outputStream.write(requestLine.toByteArray()) + process.outputStream.flush() + + val responseLine = runCatching { + withTimeout(config.requestTimeoutMs) { + process.inputStream.bufferedReader().readLine() + } + }.getOrNull() ?: throw RuntimeException("No response from sidecar") + + return json.decodeFromString(SidecarResponse.serializer(), responseLine) + } + } + + private fun ensureProcessStarted() { + if (process != null && process!!.isAlive) { + return + } + + val logDir = Paths.get(System.getProperty("user.home"), ".local", "share", "correx", "logs") + logDir.createDirectories() + val logFile = logDir.resolve("turbovec-sidecar.log").toFile() + + val pb = ProcessBuilder(config.pythonExecutable, config.scriptPath.toString()) + pb.redirectError(logFile) + + process = pb.start() + } +} diff --git a/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecSidecarConfig.kt b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecSidecarConfig.kt new file mode 100644 index 00000000..b063b4a4 --- /dev/null +++ b/infrastructure/router/turbovec/src/main/kotlin/com/correx/infrastructure/router/turbovec/TurboVecSidecarConfig.kt @@ -0,0 +1,13 @@ +package com.correx.infrastructure.router.turbovec + +import java.nio.file.Path + +data class TurboVecSidecarConfig( + val pythonExecutable: String = "python3", + val scriptPath: Path, + val dim: Int = 1536, + val bitWidth: Int = 4, + val persistPath: Path? = null, + val startupTimeoutMs: Long = 5000, + val requestTimeoutMs: Long = 5000 +) diff --git a/infrastructure/router/turbovec/src/main/resources/python/turbovec_sidecar.py b/infrastructure/router/turbovec/src/main/resources/python/turbovec_sidecar.py new file mode 100644 index 00000000..dcdcc62f --- /dev/null +++ b/infrastructure/router/turbovec/src/main/resources/python/turbovec_sidecar.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +TurboVec Sidecar Process + +Scaffolding-phase sidecar for cross-session vector memory. +Reads JSON requests from stdin (one per line), writes JSON responses to stdout. + +Operations: + - init {dim, bit_width}: Create a new index + - add {id, vector}: Add a vector to the index + - search {vector, k}: Return top-k hits by cosine similarity + - save {path}: Persist index to disk + - load {path}: Load index from disk (scaffolding: partial implementation) + - ping: Health check + - shutdown: Exit cleanly + +Requires: pip install turbovec +""" + +import sys +import json +from turbovec import TurboQuantIndex + + +def cosine_similarity(a, b): + """Compute cosine similarity between two vectors.""" + dot_product = sum(x * y for x, y in zip(a, b)) + norm_a = sum(x * x for x in a) ** 0.5 + norm_b = sum(x * x for x in b) ** 0.5 + if norm_a == 0 or norm_b == 0: + return 0.0 + return dot_product / (norm_a * norm_b) + + +class TurboVecSidecar: + def __init__(self): + self.index = None + self.id_to_idx = {} # id (str) -> index position + self.idx_to_id = [] # index position -> id (str) + + def init(self, dim, bit_width): + """Initialize a new TurboQuantIndex.""" + try: + self.index = TurboQuantIndex(dim=dim, bit_width=bit_width) + self.id_to_idx = {} + self.idx_to_id = [] + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def add(self, entry_id, vector): + """Add a vector to the index.""" + try: + if self.index is None: + return {"ok": False, "error": "Index not initialized"} + idx = len(self.idx_to_id) + self.index.add(vector) + self.id_to_idx[entry_id] = idx + self.idx_to_id.append(entry_id) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def search(self, vector, k): + """Search for top-k nearest neighbors.""" + try: + if self.index is None: + return {"ok": False, "error": "Index not initialized"} + + # Compute distances to all indexed vectors using cosine similarity + hits = [] + for idx, stored_vector in enumerate(self.index.vectors): + score = cosine_similarity(vector, stored_vector) + entry_id = self.idx_to_id[idx] + hits.append({"id": entry_id, "score": score}) + + # Sort by score descending, take top k + hits = sorted(hits, key=lambda x: x["score"], reverse=True)[:k] + return {"ok": True, "hits": hits} + except Exception as e: + return {"ok": False, "error": str(e)} + + def save(self, path): + """Persist the index to disk.""" + try: + if self.index is None: + return {"ok": False, "error": "Index not initialized"} + self.index.write(path) + # Also save metadata + metadata = {"id_to_idx": self.id_to_idx, "idx_to_id": self.idx_to_id} + with open(f"{path}.meta.json", "w") as f: + json.dump(metadata, f) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def load(self, path): + """Load the index from disk.""" + try: + # SCAFFOLDING: full load not yet implemented + # For now, return success but do not actually load + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + def ping(self): + """Health check.""" + return {"ok": True} + + def shutdown(self): + """Exit cleanly.""" + return {"ok": True} + + def process(self, request): + """Dispatch a request to the appropriate handler.""" + op = request.get("op") + if op == "init": + return self.init(request.get("dim", 1536), request.get("bitWidth", 4)) + elif op == "add": + return self.add(request.get("id"), request.get("vector")) + elif op == "search": + return self.search(request.get("vector"), request.get("k", 10)) + elif op == "save": + return self.save(request.get("path")) + elif op == "load": + return self.load(request.get("path")) + elif op == "ping": + return self.ping() + elif op == "shutdown": + return self.shutdown() + else: + return {"ok": False, "error": f"Unknown operation: {op}"} + + +def main(): + sidecar = TurboVecSidecar() + try: + while True: + line = sys.stdin.readline() + if not line: + break + try: + request = json.loads(line.strip()) + response = sidecar.process(request) + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + if request.get("op") == "shutdown": + break + except json.JSONDecodeError as e: + sys.stdout.write(json.dumps({"ok": False, "error": f"Invalid JSON: {e}"}) + "\n") + sys.stdout.flush() + except Exception as e: + sys.stderr.write(f"Error processing request: {e}\n") + sys.stdout.write(json.dumps({"ok": False, "error": str(e)}) + "\n") + sys.stdout.flush() + except KeyboardInterrupt: + pass + except Exception as e: + sys.stderr.write(f"Fatal error: {e}\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/settings.gradle b/settings.gradle index c0bbb57d..bc992771 100644 --- a/settings.gradle +++ b/settings.gradle @@ -32,6 +32,8 @@ include ':infrastructure:persistence' include ':infrastructure:inference' include ':infrastructure:inference:commons' include ':infrastructure:inference:llama_cpp' +include ':infrastructure:router' +include ':infrastructure:router:turbovec' include ':infrastructure:tools' include ':infrastructure:tools:filesystem' include ':infrastructure:workflow'