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 03ce2e4e..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,12 +1,12 @@ 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 import androidx.datastore.preferences.core.booleanPreferencesKey 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 @@ -14,6 +14,9 @@ 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, 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]") + } +}