From ce10786f8b4c60302184e72997fde3b1728273ce Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Tue, 21 Apr 2026 06:55:29 +1000 Subject: [PATCH 1/2] Fix DataStore crash: VerboseLoggingPreferenceUseCase now injects shared DataStore Prevents IllegalStateException by sharing the AboutPreferencesModule DataStore instead of creating a duplicate instance. Multiple active DataStore instances on the same file cause crashes. Fixes crash when toggling verbose logging in About screen on PR #649. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../usecase/VerboseLoggingPreferenceUseCase.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt index b750c8e1..ecd435eb 100644 --- a/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt @@ -1,28 +1,29 @@ package com.kernel.ai.core.memory.usecase -import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.kernel.ai.core.memory.rag.RagRepository -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first import javax.inject.Inject +import javax.inject.Named /** * Loads and applies the verbose logging preference from DataStore. * Called once at app startup to initialize RagRepository's verbose logging flag. + * + * Injects the DataStore from AboutPreferencesModule to avoid multiple active + * DataStore instances on the same file (which causes IllegalStateException). */ class VerboseLoggingPreferenceUseCase @Inject constructor( - @ApplicationContext private val context: Context, + @Named("about") private val dataStore: DataStore, private val ragRepository: RagRepository, ) { - private val Context.preferencesDataStore by preferencesDataStore(name = "about_prefs") suspend fun loadAndApplyVerboseLoggingPreference() { try { val keyVerboseLogging = booleanPreferencesKey("verbose_logging") - val enabled = context.preferencesDataStore.data + val enabled = dataStore.data .first() .get(keyVerboseLogging) ?: false ragRepository.setVerboseLogging(enabled) From d5f83b2c5c412ef91038f7af7cf501622f059616 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Sun, 26 Apr 2026 14:36:08 +1000 Subject: [PATCH 2/2] feat(#689): add persistent meal-planner session state to prevent RAG leakage Add conversation-scoped Room-backed session store for meal-planner state, wire it into ChatViewModel to inject structured session context into prompts and suppress episodic/RAG injection during active meal-planner turns. New files: - MealPlanSessionEntity: Room entity with conversationId PK, status, preferences, plan, and day tracking fields - MealPlanSessionDao: DAO with getByConversationId, upsert, delete methods - MealPlanSessionRepository: Convenience layer with createOrReset, updateStatus, updatePreferences, saveHighLevelPlan, advanceDay, markCompleted Changes to existing files: - KernelDatabase: add MealPlanSessionEntity, MIGRATION_23_24, version 24 - MemoryModule: add DAO/repository providers, include MIGRATION_23_24 - ChatViewModel: detect active meal-planner turns, suppress RAG context, inject structured meal-plan context block, force history replay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../24.json | 862 ++++++++++++++++++ .../kernel/ai/core/memory/KernelDatabase.kt | 27 +- .../com/kernel/ai/core/memory/MemoryModule.kt | 11 + .../ai/core/memory/dao/MealPlanSessionDao.kt | 23 + .../memory/entity/MealPlanSessionEntity.kt | 41 + .../repository/MealPlanSessionRepository.kt | 110 +++ .../VerboseLoggingPreferenceUseCase.kt | 1 + .../kernel/ai/feature/chat/ChatViewModel.kt | 59 +- 8 files changed, 1130 insertions(+), 4 deletions(-) create mode 100644 core/memory/schemas/com.kernel.ai.core.memory.KernelDatabase/24.json create mode 100644 core/memory/src/main/java/com/kernel/ai/core/memory/dao/MealPlanSessionDao.kt create mode 100644 core/memory/src/main/java/com/kernel/ai/core/memory/entity/MealPlanSessionEntity.kt create mode 100644 core/memory/src/main/java/com/kernel/ai/core/memory/repository/MealPlanSessionRepository.kt diff --git a/core/memory/schemas/com.kernel.ai.core.memory.KernelDatabase/24.json b/core/memory/schemas/com.kernel.ai.core.memory.KernelDatabase/24.json new file mode 100644 index 00000000..6f1c3b8b --- /dev/null +++ b/core/memory/schemas/com.kernel.ai.core.memory.KernelDatabase/24.json @@ -0,0 +1,862 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "69608ef9fd5afe58aa78cf745818c746", + "entities": [ + { + "tableName": "conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastDistilledAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastDistilledAt", + "columnName": "lastDistilledAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `thinkingText` TEXT, `timestamp` INTEGER NOT NULL, `toolCallJson` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`conversationId`) REFERENCES `conversations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thinkingText", + "columnName": "thinkingText", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toolCallJson", + "columnName": "toolCallJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "conversations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "conversationId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "message_embeddings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `messageId` TEXT NOT NULL, `conversationId` TEXT NOT NULL, FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_message_embeddings_messageId", + "unique": true, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_message_embeddings_messageId` ON `${TABLE_NAME}` (`messageId`)" + }, + { + "name": "index_message_embeddings_conversationId", + "unique": false, + "columnNames": [ + "conversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_embeddings_conversationId` ON `${TABLE_NAME}` (`conversationId`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `profileText` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `structuredJson` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileText", + "columnName": "profileText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "structuredJson", + "columnName": "structuredJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "episodic_memories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `accessCount` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `vectorized` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessCount", + "columnName": "accessCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vectorized", + "columnName": "vectorized", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_episodic_memories_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodic_memories_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "core_memories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `accessCount` INTEGER NOT NULL, `source` TEXT NOT NULL, `vectorized` INTEGER NOT NULL, `category` TEXT NOT NULL, `term` TEXT NOT NULL, `definition` TEXT NOT NULL, `triggerContext` TEXT NOT NULL, `vibeLevel` INTEGER NOT NULL, `metadataJson` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessCount", + "columnName": "accessCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vectorized", + "columnName": "vectorized", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "term", + "columnName": "term", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "definition", + "columnName": "definition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "triggerContext", + "columnName": "triggerContext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vibeLevel", + "columnName": "vibeLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadataJson", + "columnName": "metadataJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_core_memories_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_core_memories_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "kiwi_memories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `accessCount` INTEGER NOT NULL, `source` TEXT NOT NULL, `vectorized` INTEGER NOT NULL, `category` TEXT NOT NULL, `term` TEXT NOT NULL, `definition` TEXT NOT NULL, `triggerContext` TEXT NOT NULL, `vibeLevel` INTEGER NOT NULL, `metadataJson` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessCount", + "columnName": "accessCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vectorized", + "columnName": "vectorized", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "term", + "columnName": "term", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "definition", + "columnName": "definition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "triggerContext", + "columnName": "triggerContext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vibeLevel", + "columnName": "vibeLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadataJson", + "columnName": "metadataJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_kiwi_memories_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_kiwi_memories_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "model_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`modelId` TEXT NOT NULL, `contextWindowSize` INTEGER NOT NULL, `temperature` REAL NOT NULL, `topP` REAL NOT NULL, `topK` INTEGER NOT NULL, `showThinkingProcess` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`modelId`))", + "fields": [ + { + "fieldPath": "modelId", + "columnName": "modelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextWindowSize", + "columnName": "contextWindowSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temperature", + "columnName": "temperature", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "topP", + "columnName": "topP", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "topK", + "columnName": "topK", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showThinkingProcess", + "columnName": "showThinkingProcess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "modelId" + ] + } + }, + { + "tableName": "quick_actions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userQuery` TEXT NOT NULL, `skillName` TEXT, `resultText` TEXT NOT NULL, `isSuccess` INTEGER NOT NULL, `presentationJson` TEXT, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userQuery", + "columnName": "userQuery", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skillName", + "columnName": "skillName", + "affinity": "TEXT" + }, + { + "fieldPath": "resultText", + "columnName": "resultText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSuccess", + "columnName": "isSuccess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presentationJson", + "columnName": "presentationJson", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "scheduled_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `triggerAtMillis` INTEGER NOT NULL, `label` TEXT, `createdAt` INTEGER NOT NULL, `fired` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `entry_type` TEXT NOT NULL, `duration_ms` INTEGER, `started_at_ms` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "triggerAtMillis", + "columnName": "triggerAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fired", + "columnName": "fired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryType", + "columnName": "entry_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "startedAtMs", + "columnName": "started_at_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alias` TEXT NOT NULL, `displayName` TEXT NOT NULL, `contactId` TEXT NOT NULL, `phoneNumber` TEXT NOT NULL, PRIMARY KEY(`alias`))", + "fields": [ + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alias" + ] + } + }, + { + "tableName": "list_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `listName` TEXT NOT NULL, `item` TEXT NOT NULL, `addedAt` INTEGER NOT NULL, `checked` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listName", + "columnName": "listName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "item", + "columnName": "item", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "addedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_lists_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_lists_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "meal_plan_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `status` TEXT NOT NULL, `peopleCount` INTEGER, `days` INTEGER, `dietaryRestrictionsJson` TEXT NOT NULL, `proteinPreferencesJson` TEXT NOT NULL, `highLevelPlanJson` TEXT, `currentDayIndex` INTEGER, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`))", + "fields": [ + { + "fieldPath": "conversationId", + "columnName": "conversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peopleCount", + "columnName": "peopleCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "days", + "columnName": "days", + "affinity": "INTEGER" + }, + { + "fieldPath": "dietaryRestrictionsJson", + "columnName": "dietaryRestrictionsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proteinPreferencesJson", + "columnName": "proteinPreferencesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highLevelPlanJson", + "columnName": "highLevelPlanJson", + "affinity": "TEXT" + }, + { + "fieldPath": "currentDayIndex", + "columnName": "currentDayIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "conversationId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69608ef9fd5afe58aa78cf745818c746')" + ] + } +} \ No newline at end of file diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/KernelDatabase.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/KernelDatabase.kt index af27d41d..0665aceb 100644 --- a/core/memory/src/main/java/com/kernel/ai/core/memory/KernelDatabase.kt +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/KernelDatabase.kt @@ -7,6 +7,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.kernel.ai.core.memory.dao.ContactAliasDao import com.kernel.ai.core.memory.dao.KiwiMemoryDao +import com.kernel.ai.core.memory.dao.MealPlanSessionDao import com.kernel.ai.core.memory.dao.ListItemDao import com.kernel.ai.core.memory.dao.ListNameDao import com.kernel.ai.core.memory.dao.ConversationDao @@ -20,6 +21,7 @@ import com.kernel.ai.core.memory.dao.ScheduledAlarmDao import com.kernel.ai.core.memory.dao.UserProfileDao import com.kernel.ai.core.memory.entity.ContactAliasEntity import com.kernel.ai.core.memory.entity.KiwiMemoryEntity +import com.kernel.ai.core.memory.entity.MealPlanSessionEntity import com.kernel.ai.core.memory.entity.ListItemEntity import com.kernel.ai.core.memory.entity.ListNameEntity import com.kernel.ai.core.memory.entity.ConversationEntity @@ -47,8 +49,9 @@ import com.kernel.ai.core.memory.entity.UserProfileEntity ContactAliasEntity::class, ListItemEntity::class, ListNameEntity::class, + MealPlanSessionEntity::class, ], - version = 23, + version = 24, exportSchema = true, autoMigrations = [ AutoMigration(from = 3, to = 4), @@ -68,6 +71,7 @@ abstract class KernelDatabase : RoomDatabase() { abstract fun listItemDao(): ListItemDao abstract fun listNameDao(): ListNameDao abstract fun kiwiMemoryDao(): KiwiMemoryDao + abstract fun mealPlanSessionDao(): MealPlanSessionDao companion object { /** Adds lastDistilledAt to conversations (#165) and lastAccessedAt to episodic_memories (#167). */ @@ -277,5 +281,26 @@ abstract class KernelDatabase : RoomDatabase() { db.execSQL("ALTER TABLE quick_actions ADD COLUMN presentationJson TEXT DEFAULT NULL") } } + + /** Creates meal_plan_sessions table for conversation-scoped meal planning state (#689). */ + val MIGRATION_23_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS meal_plan_sessions ( + conversationId TEXT NOT NULL PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'collecting_preferences', + peopleCount INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + dietaryRestrictionsJson TEXT NOT NULL DEFAULT '[]', + proteinPreferencesJson TEXT NOT NULL DEFAULT '[]', + highLevelPlanJson TEXT DEFAULT NULL, + currentDayIndex INTEGER DEFAULT NULL, + updatedAt INTEGER NOT NULL + ) + """.trimIndent() + ) + } + } } } diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/MemoryModule.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/MemoryModule.kt index 1519ebeb..7cb3935b 100644 --- a/core/memory/src/main/java/com/kernel/ai/core/memory/MemoryModule.kt +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/MemoryModule.kt @@ -19,6 +19,8 @@ import com.kernel.ai.core.memory.repository.MemoryRepository import com.kernel.ai.core.memory.repository.MemoryRepositoryImpl import com.kernel.ai.core.memory.repository.ModelSettingsRepository import com.kernel.ai.core.memory.repository.ModelSettingsRepositoryImpl +import com.kernel.ai.core.memory.dao.MealPlanSessionDao +import com.kernel.ai.core.memory.repository.MealPlanSessionRepository import com.kernel.ai.core.memory.vector.SqliteVecStore import com.kernel.ai.core.memory.vector.VectorStore import dagger.Binds @@ -71,6 +73,7 @@ abstract class MemoryModule { KernelDatabase.MIGRATION_20_21, KernelDatabase.MIGRATION_21_22, KernelDatabase.MIGRATION_22_23, + KernelDatabase.MIGRATION_23_24, ) .build() @@ -118,5 +121,13 @@ abstract class MemoryModule { @Singleton fun provideContactAliasRepository(dao: ContactAliasDao): ContactAliasRepository = ContactAliasRepository(dao) + + @Provides + fun provideMealPlanSessionDao(db: KernelDatabase): MealPlanSessionDao = db.mealPlanSessionDao() + + @Provides + @Singleton + fun provideMealPlanSessionRepository(dao: MealPlanSessionDao): MealPlanSessionRepository = + MealPlanSessionRepository(dao) } } diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/dao/MealPlanSessionDao.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/dao/MealPlanSessionDao.kt new file mode 100644 index 00000000..41e9cecf --- /dev/null +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/dao/MealPlanSessionDao.kt @@ -0,0 +1,23 @@ +package com.kernel.ai.core.memory.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.kernel.ai.core.memory.entity.MealPlanSessionEntity + +@Dao +interface MealPlanSessionDao { + + @Query("SELECT * FROM meal_plan_sessions WHERE conversationId = :conversationId") + suspend fun getByConversationId(conversationId: String): MealPlanSessionEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(session: MealPlanSessionEntity) + + @Query("DELETE FROM meal_plan_sessions WHERE conversationId = :conversationId") + suspend fun deleteByConversationId(conversationId: String) + + @Query("DELETE FROM meal_plan_sessions") + suspend fun deleteAll() +} diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/entity/MealPlanSessionEntity.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/entity/MealPlanSessionEntity.kt new file mode 100644 index 00000000..83d4b2f4 --- /dev/null +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/entity/MealPlanSessionEntity.kt @@ -0,0 +1,41 @@ +package com.kernel.ai.core.memory.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Persists the active meal-planner session state for a single conversation. + * + * Scoped by [conversationId] — one session per conversation. + * Structured JSON fields (dietaryRestrictionsJson, proteinPreferencesJson, highLevelPlanJson) + * future-proof the seam toward the generic artifact system (#235). + */ +@Entity(tableName = "meal_plan_sessions") +data class MealPlanSessionEntity( + @PrimaryKey val conversationId: String, + + /** Current flow stage. Values: collecting_preferences, high_level_plan_ready, + * generating_recipes, completed. */ + val status: String = "collecting_preferences", + + /** Number of people the plan is for. */ + val peopleCount: Int? = null, + + /** Number of days in the plan. */ + val days: Int? = null, + + /** Compact JSON array of dietary restrictions, e.g. ["vegetarian","gluten-free"]. */ + val dietaryRestrictionsJson: String = "[]", + + /** Compact JSON array of protein preferences, e.g. ["chicken","fish"]. */ + val proteinPreferencesJson: String = "[]", + + /** Assistant-generated high-level plan text (one-line summary per day). */ + val highLevelPlanJson: String? = null, + + /** Index of the day currently being detailed (0-based). */ + val currentDayIndex: Int? = null, + + /** Last update timestamp. */ + val updatedAt: Long = System.currentTimeMillis(), +) diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/repository/MealPlanSessionRepository.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/repository/MealPlanSessionRepository.kt new file mode 100644 index 00000000..daa28e9d --- /dev/null +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/repository/MealPlanSessionRepository.kt @@ -0,0 +1,110 @@ +package com.kernel.ai.core.memory.repository + +import com.kernel.ai.core.memory.dao.MealPlanSessionDao +import com.kernel.ai.core.memory.entity.MealPlanSessionEntity +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Conversation-scoped repository for meal-planner session state. + * + * One session per conversation. Used by [ChatViewModel] to inject structured + * session context into meal-planner prompts and to persist state transitions + * across the multi-stage flow. + */ +@Singleton +class MealPlanSessionRepository @Inject constructor( + private val dao: MealPlanSessionDao, +) { + + suspend fun getSession(conversationId: String): MealPlanSessionEntity? = + dao.getByConversationId(conversationId) + + suspend fun upsert(session: MealPlanSessionEntity) { + dao.upsert(session.copy(updatedAt = System.currentTimeMillis())) + } + + /** + * Creates or resets the session for [conversationId] to initial collecting state. + * Clears all structured fields while preserving the conversationId primary key. + */ + suspend fun createOrReset(conversationId: String) { + val session = MealPlanSessionEntity( + conversationId = conversationId, + status = "collecting_preferences", + peopleCount = null, + days = null, + dietaryRestrictionsJson = "[]", + proteinPreferencesJson = "[]", + highLevelPlanJson = null, + currentDayIndex = null, + ) + dao.upsert(session) + } + + suspend fun deleteByConversationId(conversationId: String) { + dao.deleteByConversationId(conversationId) + } + + suspend fun deleteAll() { + dao.deleteAll() + } + + // ── Convenience mutators ── + + suspend fun updateStatus(conversationId: String, status: String) { + val existing = dao.getByConversationId(conversationId) + ?: return // nothing to update + upsert(existing.copy(status = status, updatedAt = System.currentTimeMillis())) + } + + suspend fun updatePreferences( + conversationId: String, + peopleCount: Int? = null, + days: Int? = null, + dietaryRestrictionsJson: String? = null, + proteinPreferencesJson: String? = null, + ) { + val existing = dao.getByConversationId(conversationId) + ?: return + upsert( + existing.copy( + peopleCount = peopleCount ?: existing.peopleCount, + days = days ?: existing.days, + dietaryRestrictionsJson = dietaryRestrictionsJson ?: existing.dietaryRestrictionsJson, + proteinPreferencesJson = proteinPreferencesJson ?: existing.proteinPreferencesJson, + updatedAt = System.currentTimeMillis(), + ), + ) + } + + suspend fun saveHighLevelPlan(conversationId: String, planJson: String) { + val existing = dao.getByConversationId(conversationId) + ?: return + upsert( + existing.copy( + highLevelPlanJson = planJson, + status = "high_level_plan_ready", + updatedAt = System.currentTimeMillis(), + ), + ) + } + + suspend fun advanceDay(conversationId: String) { + val existing = dao.getByConversationId(conversationId) + ?: return + val nextDay = (existing.currentDayIndex ?: 0) + 1 + upsert( + existing.copy( + currentDayIndex = nextDay, + status = if (existing.days != null && nextDay >= existing.days) "completed" + else "generating_recipes", + updatedAt = System.currentTimeMillis(), + ), + ) + } + + suspend fun markCompleted(conversationId: String) { + updateStatus(conversationId, "completed") + } +} diff --git a/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt b/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt index e4bceeb8..45aae4ee 100644 --- a/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt +++ b/core/memory/src/main/java/com/kernel/ai/core/memory/usecase/VerboseLoggingPreferenceUseCase.kt @@ -1,6 +1,7 @@ package com.kernel.ai.core.memory.usecase import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext import android.content.pm.ApplicationInfo import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index 8d462de4..a8fc66d1 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -30,6 +30,7 @@ import com.kernel.ai.core.memory.repository.ConversationRepository import com.kernel.ai.core.memory.repository.MemoryRepository import com.kernel.ai.core.memory.repository.ModelSettingsRepository import com.kernel.ai.core.memory.repository.UserProfileRepository +import com.kernel.ai.core.memory.repository.MealPlanSessionRepository import com.kernel.ai.core.memory.usecase.EpisodicDistillationUseCase import com.kernel.ai.core.skills.KernelAIToolSet import com.kernel.ai.core.skills.QuickIntentRouter @@ -96,6 +97,7 @@ class ChatViewModel @Inject constructor( private val jandalPersona: JandalPersona, private val nzTruthSeedingService: NzTruthSeedingService, private val verboseLoggingPreferenceUseCase: com.kernel.ai.core.memory.usecase.VerboseLoggingPreferenceUseCase, + private val mealPlanSessionRepository: MealPlanSessionRepository, ) : ViewModel() { val isSeeding: StateFlow = nzTruthSeedingService.isSeeding @@ -942,6 +944,25 @@ class ChatViewModel @Inject constructor( val effectiveRagContext = if (isToolQuery) "" else ragContext val effectiveRagTokenCost = if (isToolQuery) 0 else ragTokenCost + // Meal-planner session context (#689): when an active session exists, inject + // structured session state so the model can continue deterministically across + // follow-up turns. Also suppress episodic/RAG injection for these turns to + // prevent stale memory leakage (#687). + val cid = conversationId + val isActiveMealPlannerTurn = cid != null && + mealPlanSessionRepository.getSession(cid)?.let { it.status != "completed" } == true + val mealPlanContext = if (isActiveMealPlannerTurn && cid != null) { + val session = mealPlanSessionRepository.getSession(cid) + buildMealPlanContext(session) + } else "" + // Suppress RAG for active meal-planner turns — session state is the source of truth. + val effectiveRagContextForPrompt = if (isActiveMealPlannerTurn) "" else effectiveRagContext + // Force history replay when meal-planner session is active — clears stale episodic + // context from the KV cache so old meal-plan memories cannot leak into the continuation. + if (isActiveMealPlannerTurn) { + needsHistoryReplay = true + } + // Anaphora handling (#491): tool queries with "save that", "look it up", etc. need // the previous turn to resolve what "that/it/this" refers to. Inject the last // user+assistant pair as a lightweight context block — still no RAG or personality. @@ -981,10 +1002,11 @@ class ChatViewModel @Inject constructor( } prompt = buildString { - if (effectiveRagContext.isNotBlank()) append("$effectiveRagContext\n\n") + if (effectiveRagContextForPrompt.isNotBlank()) append("$effectiveRagContextForPrompt\n\n") + if (mealPlanContext.isNotBlank()) append("$mealPlanContext\n\n") if (anaphoraContext.isNotBlank()) append("$anaphoraContext\n\n") if (systemContext != null) append("$systemContext\n\n") - if (effectiveRagContext.isNotBlank() || systemContext != null) { + if (effectiveRagContextForPrompt.isNotBlank() || systemContext != null) { append("[System: If the answer depends on provided context, memory, or tool output, copy exact dates, numbers, names, titles, and quoted phrases exactly as written. You may still explain or analyse them when the user asks, but do not mutate literal facts. If the exact detail is not present, say you are not sure.]\n\n") } if (isToolQuery) { @@ -1001,7 +1023,7 @@ class ChatViewModel @Inject constructor( append(text) } groundingContext = buildString { - if (effectiveRagContext.isNotBlank()) append(effectiveRagContext) + if (effectiveRagContextForPrompt.isNotBlank()) append(effectiveRagContextForPrompt) if (systemContext != null) { if (isNotBlank()) append('\n') append(systemContext) @@ -1613,3 +1635,34 @@ private fun formatBytes(bytes: Long): String = when { bytes >= 1_048_576L -> "%.0f MB".format(bytes / 1_048_576.0) else -> "$bytes B" } + +/** + * Builds a compact meal-planner session context block for injection into prompts. + * + * When an active meal-plan session exists, this provides the model with structured + * state (preferences, plan, current day) so it can continue deterministically + * across follow-up turns without re-asking already-known information. + */ +private fun buildMealPlanContext(session: com.kernel.ai.core.memory.entity.MealPlanSessionEntity?): String { + if (session == null) return "" + return buildString { + append("[Meal Planner Session]\n") + append("Status: ${session.status}\n") + session.peopleCount?.let { append("People: $it\n") } + session.days?.let { append("Days: $it\n") } + if (session.dietaryRestrictionsJson != "[]") { + append("Dietary: ${session.dietaryRestrictionsJson}\n") + } + if (session.proteinPreferencesJson != "[]") { + append("Proteins: ${session.proteinPreferencesJson}\n") + } + session.highLevelPlanJson?.let { plan -> + append("Plan: $plan\n") + } + session.currentDayIndex?.let { idx -> + val dayLabel = idx + 1 + append("Current day: $dayLabel\n") + } + append("[End Meal Planner Session]") + } +}