Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
862 changes: 862 additions & 0 deletions core/memory/schemas/com.kernel.ai.core.memory.KernelDatabase/24.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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). */
Expand Down Expand Up @@ -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()
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +73,7 @@ abstract class MemoryModule {
KernelDatabase.MIGRATION_20_21,
KernelDatabase.MIGRATION_21_22,
KernelDatabase.MIGRATION_22_23,
KernelDatabase.MIGRATION_23_24,
)
.build()

Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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(),
)
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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

/**
* 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Boolean> = nzTruthSeedingService.isSeeding
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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]")
}
}
Loading