From e2490ce8b77878f58d857e15f259119495c198a5 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:44:05 +0000 Subject: [PATCH 01/14] =?UTF-8?q?feat(memory):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=B1=82=E6=94=AF=E6=8C=81=E8=AE=B0=E5=BF=86=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=88NOTE/PROFILE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长期记忆重做第一步:为「显式记忆」与「自动沉淀结论」提供区分依据。 - Memory 加 kind 字段(默认 NOTE),MemoryKind 枚举新增 - MemoryParser 读写 frontmatter 的 kind 字段;NOTE 不写该字段, 既有记忆文件字节不变(未知/缺失 kind 一律回退 NOTE) - MemorySource.saveMemory 加 kind 参数(默认 NOTE),editMemory 回写时保留原 kind - MemoryRepository.listMemories 支持按 kind 过滤,saveMemory 透传 kind - 补单测:profile 往返、NOTE 不写字段、缺失/未知 kind 回退 本步不动界面,模型工具行为不变。 --- .../agent/domain/memory/GlobalMemorySource.kt | 4 +- .../feature/agent/domain/memory/Memory.kt | 12 +++++- .../agent/domain/memory/MemoryParser.kt | 12 ++++-- .../agent/domain/memory/MemoryRepository.kt | 20 +++++++--- .../agent/domain/memory/MemorySource.kt | 9 ++++- .../domain/memory/ProjectMemorySource.kt | 4 +- .../agent/domain/memory/MemoryParserTest.kt | 37 +++++++++++++++++++ 7 files changed, 82 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt index 97a855a5f..2ef929233 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/GlobalMemorySource.kt @@ -29,11 +29,11 @@ class GlobalMemorySource @Inject constructor( ?.content } - override fun saveMemory(name: String, description: String, content: String): Boolean { + override fun saveMemory(name: String, description: String, content: String, kind: MemoryKind): Boolean { return try { if (!memoryRoot.exists()) memoryRoot.mkdirs() val file = MemorySource.resolveMemoryFile(memoryRoot, name) - file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content)) + file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content, kind)) true } catch (e: Exception) { FileLogger.e("GlobalMemorySource", "Failed to save memory: $name", e) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt index cb5f49574..589dd14e5 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/Memory.kt @@ -16,9 +16,19 @@ data class Memory( val description: String, val scope: MemoryScope, val file: File? = null, - val content: String + val content: String, + val kind: MemoryKind = MemoryKind.NOTE ) enum class MemoryScope { GLOBAL, PROJECT } + +/** + * 记忆类型:[NOTE] 是模型显式记录的内容,[PROFILE] 是从历史对话自动沉淀的长期结论。 + * + * 仅作 frontmatter 的 kind 字段落盘,[NOTE] 不写该字段——保持既有记忆文件字节不变。 + */ +enum class MemoryKind { + NOTE, PROFILE +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt index 62cf4d904..9bd7f2ab6 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryParser.kt @@ -21,20 +21,26 @@ object MemoryParser { val name = frontmatter["name"]?.toString()?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension val description = (frontmatter["description"]?.toString() ?: "").take(MAX_DESC_CHARS) + val kind = when (frontmatter["kind"]?.toString()?.trim()?.lowercase()) { + "profile" -> MemoryKind.PROFILE + else -> MemoryKind.NOTE + } return Memory( name = name, description = description, scope = scope, file = file, - content = body.trim() + content = body.trim(), + kind = kind ) } - fun format(name: String, description: String, content: String): String { + fun format(name: String, description: String, content: String, kind: MemoryKind = MemoryKind.NOTE): String { val safeName = yamlScalar(name) val safeDesc = yamlScalar(description) - return "---\nname: $safeName\ndescription: $safeDesc\n---\n$content" + val kindLine = if (kind == MemoryKind.PROFILE) "kind: profile\n" else "" + return "---\nname: $safeName\ndescription: $safeDesc\n$kindLine---\n$content" } /** 把任意字符串转成安全的 YAML 标量,避免冒号/引号/换行破坏 frontmatter。 */ diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt index 6beae5db0..080ee54a3 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt @@ -17,8 +17,8 @@ class MemoryRepository @Inject constructor( private fun projectSource(projectRoot: String) = ProjectMemorySource(projectRoot, executionModeHolder, containerInstaller, projectAicodeRoot) - /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。 */ - fun listMemories(projectRoot: String?): List { + /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。kind 非空时只返回该类型。 */ + fun listMemories(projectRoot: String?, kind: MemoryKind? = null): List { val allMemories = mutableListOf() // 1. 加载全局记忆 @@ -30,9 +30,10 @@ class MemoryRepository @Inject constructor( } // 去重:按 name 小写分组,保留最后加入的(即项目级优先覆盖全局级) - return allMemories + val deduped = allMemories .groupBy { it.name.lowercase() } .map { it.value.last() } + return if (kind == null) deduped else deduped.filter { it.kind == kind } } /** 读取指定 memory 的完整指令正文;不存在 / 解析失败返回 null。 */ @@ -46,12 +47,19 @@ class MemoryRepository @Inject constructor( return globalMemorySource.loadContent(name) } - fun saveMemory(name: String, description: String, content: String, scope: MemoryScope, projectRoot: String?): Boolean { + fun saveMemory( + name: String, + description: String, + content: String, + scope: MemoryScope, + projectRoot: String?, + kind: MemoryKind = MemoryKind.NOTE + ): Boolean { return when (scope) { - MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content) + MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content, kind) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) false - else projectSource(projectRoot).saveMemory(name, description, content) + else projectSource(projectRoot).saveMemory(name, description, content, kind) } } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt index aa73c6bf4..4c59b342c 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemorySource.kt @@ -33,7 +33,12 @@ interface MemorySource { fun loadContent(name: String): String? /** 保存一条记忆(创建或覆盖) */ - fun saveMemory(name: String, description: String, content: String): Boolean + fun saveMemory( + name: String, + description: String, + content: String, + kind: MemoryKind = MemoryKind.NOTE + ): Boolean /** * 对已有记忆的正文做局部编辑(old_string/new_string 精确匹配),语义与 editFile 一致。 @@ -74,7 +79,7 @@ interface MemorySource { } return try { - file.writeText(MemoryParser.format(memory.name, memory.description, content)) + file.writeText(MemoryParser.format(memory.name, memory.description, content, memory.kind)) MemoryEditResult.Success } catch (e: Exception) { FileLogger.e("MemorySource", "Failed to edit memory: $name", e) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt index f387c1f23..dc8fd355d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/ProjectMemorySource.kt @@ -46,12 +46,12 @@ class ProjectMemorySource( ?.content } - override fun saveMemory(name: String, description: String, content: String): Boolean { + override fun saveMemory(name: String, description: String, content: String, kind: MemoryKind): Boolean { if (projectRoot.isBlank()) return false return try { if (!memoryRoot.exists()) memoryRoot.mkdirs() val file = MemorySource.resolveMemoryFile(memoryRoot, name) - file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content)) + file.writeText(MemoryParser.format(MemorySource.sanitizeName(name), description, content, kind)) true } catch (e: Exception) { FileLogger.e("ProjectMemorySource", "Failed to save memory: $name", e) diff --git a/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt index 8d39b9d73..4bd03637f 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/memory/MemoryParserTest.kt @@ -1,6 +1,7 @@ package com.aicode.feature.agent.domain.memory import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -45,4 +46,40 @@ class MemoryParserTest { assertEquals("Description with \"quotes\" and : colons", memory?.description) assertEquals("Memory body", memory?.content) } + + @Test + fun profileKind_roundtripsThroughFrontmatter() { + val formatted = MemoryParser.format("pref_concise", "Prefers concise answers", "body", MemoryKind.PROFILE) + assertTrue(formatted.contains("kind: profile")) + + val file = tempFolder.newFile("pref_concise.md") + file.writeText(formatted) + + val memory = MemoryParser.parse(file, MemoryScope.GLOBAL) + assertEquals(MemoryKind.PROFILE, memory?.kind) + assertEquals("Prefers concise answers", memory?.description) + assertEquals("body", memory?.content) + } + + @Test + fun noteKind_doesNotWriteKindField() { + val formatted = MemoryParser.format("plain", "plain note", "body") + assertFalse(formatted.contains("kind:")) + + val file = tempFolder.newFile("plain.md") + file.writeText(formatted) + + assertEquals(MemoryKind.NOTE, MemoryParser.parse(file, MemoryScope.GLOBAL)?.kind) + } + + @Test + fun unknownOrMissingKind_fallsBackToNote() { + val file = tempFolder.newFile("legacy.md") + file.writeText("---\nname: legacy\ndescription: old file\n---\nbody") + assertEquals(MemoryKind.NOTE, MemoryParser.parse(file, MemoryScope.GLOBAL)?.kind) + + val weird = tempFolder.newFile("weird.md") + weird.writeText("---\nname: weird\nkind: something-else\n---\nbody") + assertEquals(MemoryKind.NOTE, MemoryParser.parse(weird, MemoryScope.GLOBAL)?.kind) + } } From 549a1b92e3753a1a8535d7d866fd3b99fdb5f555 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:51:05 +0000 Subject: [PATCH 02/14] =?UTF-8?q?feat(memory):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=E3=80=8C=E8=AE=B0=E5=BF=86=E3=80=8D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=B8=8E=E5=88=97=E8=A1=A8=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 长期记忆重做第二步:把原本完全不可见的记忆暴露出来。 - SettingsSection 新增 Memory(一级分区,放在「技能」「子代理」之后) - 新增 MemoryViewModel:经 MemoryRepository 扫描当前生效的记忆,支持删除(IO 线程) - 新增 MemorySection:照技能页样式,空状态 + 卡片列表 + 左滑删除; 全局与项目记忆合并成一份清单,不分栏 - 中英文案各 3 条(settings_memory / memory_empty / memory_empty_hint) 本步只做「看 + 删」,自动沉淀与开关在后续步骤。 --- .../settings/presentation/MemoryViewModel.kt | 53 ++++++ .../presentation/component/MemorySection.kt | 158 ++++++++++++++++++ .../presentation/component/SettingsScreen.kt | 17 ++ app/src/main/res/values-en/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + 5 files changed, 238 insertions(+) create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt new file mode 100644 index 000000000..dd37f4e47 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt @@ -0,0 +1,53 @@ +package com.aicode.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.workspace.data.repository.WorkspaceRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * 记忆页状态:列出当前生效的记忆(全局与项目由 Repository 合并去重,界面不再分栏),支持删除。 + * + * 扫描的是磁盘上的记忆文件,同为 IO,故与删除一起放到 IO 线程。 + */ +@HiltViewModel +class MemoryViewModel @Inject constructor( + private val memoryRepository: MemoryRepository, + private val workspaceRepository: WorkspaceRepository +) : ViewModel() { + + private val _memories = MutableStateFlow>(emptyList()) + val memories: StateFlow> = _memories.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + _memories.value = withContext(Dispatchers.IO) { + memoryRepository.listMemories(projectRoot) + } + } + } + + fun delete(memory: Memory) { + viewModelScope.launch { + val projectRoot = workspaceRepository.currentPath() + withContext(Dispatchers.IO) { + memoryRepository.deleteMemory(memory.name, memory.scope, projectRoot) + } + refresh() + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt new file mode 100644 index 000000000..02ec87e34 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt @@ -0,0 +1,158 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.aicode.R +import com.aicode.core.theme.Radius +import com.aicode.core.theme.Spacing +import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.SwipeToDeleteRow +import com.aicode.feature.agent.domain.memory.Memory +import compose.icons.FeatherIcons +import compose.icons.feathericons.FileText + +/** + * 记忆页:列出当前生效的记忆,左滑删除。 + * + * 全局与项目记忆合并成一份清单、不分栏——用户看到的只是「AI 记住了什么」, + * 记忆存在哪一侧是实现细节。 + */ +@Composable +internal fun MemorySection( + memories: List, + onDelete: (Memory) -> Unit +) { + if (memories.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Spacing.xl, vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.lg)), + contentAlignment = Alignment.Center + ) { + Icon( + FeatherIcons.FileText, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.memory_empty), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.memory_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroup { + memories.forEachIndexed { index, memory -> + if (index > 0) SettingsDivider() + MemoryRow(memory = memory, onDelete = { onDelete(memory) }) + } + } + } +} + +/** 单条记忆行:图标 + 名称/描述,左滑删除。 */ +@Composable +private fun MemoryRow( + memory: Memory, + onDelete: () -> Unit +) { + val rowBackground = MaterialTheme.semanticColors.cardSurface + + SwipeToDeleteRow(onDelete = onDelete) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(rowBackground) + .padding(start = Spacing.lg, end = Spacing.lg, top = 11.dp, bottom = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = FeatherIcons.FileText, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(Spacing.md)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = memory.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = memory.description.ifBlank { stringResource(R.string.mcp_no_description) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index 6f0180b34..1d99208ee 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -144,6 +144,7 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { DefaultModels(R.string.settings_default_models), Mcp(R.string.settings_mcp), Skills(R.string.settings_skills), + Memory(R.string.settings_memory), SkillDetail(R.string.settings_skills), SkillEditor(R.string.settings_skills), SubAgents(R.string.settings_subagents), @@ -822,6 +823,16 @@ fun SettingsScreen( section = SettingsSection.SkillDetail } ) + SettingsSection.Memory -> { + // 记忆页的 VM 只在这一分区创建,避免每次进设置页都扫一遍记忆目录 + val memoryViewModel: com.aicode.feature.settings.presentation.MemoryViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val memories by memoryViewModel.memories.collectAsStateWithLifecycle() + MemorySection( + memories = memories, + onDelete = memoryViewModel::delete + ) + } SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( entry = entry, @@ -1348,6 +1359,12 @@ internal fun SettingsMenu( title = stringResource(SettingsSection.SubAgents.titleRes), onClick = { onOpen(SettingsSection.SubAgents) } ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.FileText, + title = stringResource(SettingsSection.Memory.titleRes), + onClick = { onOpen(SettingsSection.Memory) } + ) } // ── 运行环境 ── diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index ec1b75499..09a619ef0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -50,6 +50,7 @@ Image Generation Model MCP Servers Skills + Memory Container & Images No log files yet Failed to read log: %1$s @@ -965,6 +966,10 @@ %1$d tools Details + + No memories yet + Whatever you ask the assistant to remember shows up here + No skills installed Tap + in the top bar to create or import one. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b1015eb2..0ce2adc59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,7 @@ 生图模型 MCP 服务器 技能 + 记忆 容器与镜像 还没有日志文件 读取日志失败:%1$s @@ -965,6 +966,10 @@ %1$d 个工具 详情 + + 还没有记忆 + 对话中让 AI 记住的内容会显示在这里 + 还没有安装技能 点右上角「+」新建或导入 From fffba542d911b3cd0b4d63532a898a8deb7778e1 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:24:22 +0000 Subject: [PATCH 03/14] =?UTF-8?q?feat(engine):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=BC=95=E6=93=8E=E6=A1=86=E6=9E=B6=EF=BC=8C?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E4=BD=9C=E4=B8=BA=E7=AC=AC=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能以后都做成引擎模块,由引擎统一调度;本次先落地框架 + 记忆模块(行为等价重构)。 框架(feature/agent/domain/engine): - EngineModule:模块接口——promptFragment / tools / onTurnCompleted / onSessionDeleted, 默认空实现,模块只覆写关心的;id + order 决定调度顺序 - EngineContext:模块可见的全部输入(会话、工作区、模式、历史、是否子代理) - AgentEngine:统一调度——按 order 聚合片段、聚合模块工具(同名以内置优先)、 并发分发轮次/会话钩子;单个模块抛错只记日志,不影响其它模块与主流程 记忆迁入(modules/MemoryModule): - 记忆清单注入从 SystemPromptProvider.MemoryListSource 整体迁入(含会话级缓存), 子代理的 InjectPart.MEMORY 门禁不变,行为等价 - onSessionDeleted 释放该会话的注入缓存 接线: - SystemPromptProvider:MemoryListSource → EngineFragmentSource(委托引擎) - AIAgentViewModel:工具集合并模块工具;一轮正常结束时 engine.onTurnCompleted - SessionUseCase.deleteSession:删除后 engine.onSessionDeleted - DI:EngineBindingsModule(@Binds @IntoSet)+ CoroutineScopesModule(@ApplicationScope) 测试:AgentEngineTest(顺序/空白跳过/异常隔离/工具去重/钩子分发)+ 修既有 SessionUseCase 单测构造 --- .../com/aicode/di/CoroutineScopesModule.kt | 30 +++++ .../com/aicode/di/EngineBindingsModule.kt | 25 ++++ .../agent/domain/engine/AgentEngine.kt | 90 +++++++++++++ .../agent/domain/engine/EngineModule.kt | 57 +++++++++ .../domain/engine/modules/MemoryModule.kt | 79 ++++++++++++ .../domain/prompt/SystemPromptProvider.kt | 68 ++++------ .../agent/domain/session/SessionUseCase.kt | 9 +- .../agent/presentation/AIAgentViewModel.kt | 27 +++- .../agent/domain/engine/AgentEngineTest.kt | 119 ++++++++++++++++++ .../SessionUseCaseWorkspaceDeletionTest.kt | 11 +- 10 files changed, 465 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/aicode/di/CoroutineScopesModule.kt create mode 100644 app/src/main/java/com/aicode/di/EngineBindingsModule.kt create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt create mode 100644 app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt diff --git a/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt b/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt new file mode 100644 index 000000000..449ac1c1c --- /dev/null +++ b/app/src/main/java/com/aicode/di/CoroutineScopesModule.kt @@ -0,0 +1,30 @@ +package com.aicode.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +/** 应用级长生命周期作用域:用于「不跟随调用方生命周期」的后台分发。 */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopesModule { + + /** + * SupervisorJob:单个子任务失败不连坐其它子任务(引擎分发钩子依赖这一点)。 + * 提供为可注入项而非引擎内部 new,是为了单测能换成 TestScope 做确定性验证。 + */ + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +} diff --git a/app/src/main/java/com/aicode/di/EngineBindingsModule.kt b/app/src/main/java/com/aicode/di/EngineBindingsModule.kt new file mode 100644 index 000000000..2f468f6df --- /dev/null +++ b/app/src/main/java/com/aicode/di/EngineBindingsModule.kt @@ -0,0 +1,25 @@ +package com.aicode.di + +import com.aicode.feature.agent.domain.engine.EngineModule +import com.aicode.feature.agent.domain.engine.modules.MemoryModule +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +/** + * 引擎模块注册:把每个 [EngineModule] 实现汇集为 Set,供 + * [com.aicode.feature.agent.domain.engine.AgentEngine] 构造注入。 + * + * 新增一个引擎模块时,在这里追加一行 `@Binds @IntoSet` 绑定即可, + * 引擎侧不需要任何改动。 + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class EngineBindingsModule { + + @Binds + @IntoSet + abstract fun bindMemoryModule(module: MemoryModule): EngineModule +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt new file mode 100644 index 000000000..190fb45cb --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/AgentEngine.kt @@ -0,0 +1,90 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.core.util.FileLogger +import com.aicode.di.ApplicationScope +import com.aicode.feature.agent.domain.tool.AgentTool +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 统一智能调度:所有 [EngineModule] 由这里调度,调用方只跟引擎打交道。 + * + * 三件事: + * 1. 片段聚合——按 [EngineModule.order] 取各模块本轮的系统提示词片段,拼成一段; + * 2. 工具聚合——收集各模块额外提供的工具(同名以内置/先注册者为准,由调用方去重); + * 3. 钩子分发——轮次结束、会话删除这类「一次触发、多个模块响应」的动作,并发分发。 + * + * 隔离策略:单个模块抛异常只记日志,绝不影响其它模块,也不向上抛—— + * 一个模块出问题不能拖垮整轮对话。 + */ +@Singleton +class AgentEngine @Inject constructor( + private val modules: Set<@JvmSuppressWildcards EngineModule>, + @param:ApplicationScope private val dispatchScope: CoroutineScope +) { + + /** 各模块本轮片段按 order 拼接;全部为空时返回 null。 */ + fun promptFragment(ctx: EngineContext): String? { + val pieces = sortedModules().mapNotNull { module -> + runModule(module, "promptFragment") { it.promptFragment(ctx) } + ?.takeIf { it.isNotBlank() } + } + return pieces.takeIf { it.isNotEmpty() }?.joinToString("\n\n") + } + + /** 各模块本轮提供的工具,按模块顺序拼接,同名只保留第一个。 */ + fun tools(ctx: EngineContext): List = + sortedModules() + .flatMap { module -> runModule(module, "tools") { it.tools(ctx) } ?: emptyList() } + .distinctBy { it.name } + + /** 一轮对话正常结束:并发分发给所有模块,调用方不等待。 */ + fun onTurnCompleted(ctx: EngineContext) { + dispatch("onTurnCompleted") { it.onTurnCompleted(ctx) } + } + + /** 会话被删除:并发分发给所有模块,调用方不等待。 */ + fun onSessionDeleted(ctx: EngineContext) { + dispatch("onSessionDeleted") { it.onSessionDeleted(ctx) } + } + + private fun dispatch(action: String, block: suspend (EngineModule) -> Unit) { + sortedModules().forEach { module -> + dispatchScope.launch { + runSuspendModule(module, action) { block(it) } + } + } + } + + private fun sortedModules(): List = + modules.sortedWith(compareBy({ it.order }, { it.id })) + + private inline fun runModule( + module: EngineModule, + action: String, + block: (EngineModule) -> T + ): T? = try { + block(module) + } catch (e: Exception) { + FileLogger.w(TAG, "模块 ${module.id} 的 $action 失败,已跳过", e) + null + } + + private suspend inline fun runSuspendModule( + module: EngineModule, + action: String, + block: suspend (EngineModule) -> Unit + ) { + try { + block(module) + } catch (e: Exception) { + FileLogger.w(TAG, "模块 ${module.id} 的 $action 失败,已跳过", e) + } + } + + private companion object { + const val TAG = "AgentEngine" + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt new file mode 100644 index 000000000..a5abecc76 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt @@ -0,0 +1,57 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.agent.domain.model.AgentMode +import com.aicode.feature.agent.domain.tool.AgentTool + +/** + * 引擎上下文:模块在一次调度里能看到的全部输入。 + * + * 只带「模块做判断需要的信息」,不暴露 Workflow/ViewModel 等上层对象, + * 避免模块反向依赖调用链。 + */ +data class EngineContext( + val sessionId: String?, + /** 会话被删除等拿不到工作区的场景允许为空串。 */ + val projectRoot: String = "", + val mode: AgentMode = AgentMode.BUILD, + val history: List = emptyList(), + /** 是否为子代理会话(按自定义子代理定义运行)。 */ + val isSubAgent: Boolean = false +) + +/** + * 引擎模块:一个功能以模块为单位接入引擎,由 [AgentEngine] 统一调度。 + * + * 所有回调都有默认空实现,模块只覆写自己关心的那几个: + * - [promptFragment]:本轮要不要往系统提示词里加东西; + * - [tools]:本轮要不要额外提供工具; + * - [onTurnCompleted]:一轮对话正常结束后的沉淀/维护; + * - [onSessionDeleted]:会话被删除后的清理(模块自持的会话级状态在这里释放)。 + * + * 模块内抛出的异常由引擎兜住(记日志、不影响其它模块与主流程), + * 所以模块不必自己写 try/catch。 + */ +interface EngineModule { + /** 模块标识,用于日志与排查。 */ + val id: String + + /** 调度顺序,越小越靠前;同序按 [id] 稳定排序。 */ + val order: Int get() = DEFAULT_ORDER + + /** 本轮注入系统提示词的片段;返回 null 或空白表示本轮不参与。 */ + fun promptFragment(ctx: EngineContext): String? = null + + /** 本轮额外提供的工具;与内置工具合并时同名以内置为准。 */ + fun tools(ctx: EngineContext): List = emptyList() + + /** 一轮对话正常结束(成功、未取消)后调用。 */ + suspend fun onTurnCompleted(ctx: EngineContext) {} + + /** 会话被删除后调用。 */ + suspend fun onSessionDeleted(ctx: EngineContext) {} + + companion object { + const val DEFAULT_ORDER = 100 + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt new file mode 100644 index 000000000..022cefafa --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -0,0 +1,79 @@ +package com.aicode.feature.agent.domain.engine.modules + +import com.aicode.feature.agent.domain.engine.EngineContext +import com.aicode.feature.agent.domain.engine.EngineModule +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.agent.domain.memory.MemoryScope +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 记忆模块:把「AI 记住了什么」接进引擎。 + * + * 目前只负责注入——把记忆清单(名称 + 描述)拼进系统提示词,详情仍由模型调 + * `memory(action=read)` 自取;自动沉淀等能力后续接在同一模块内。 + * + * 缓存策略沿用迁移前的实现:按 (sessionId, projectRoot) 会话级缓存,同一会话内只读一次盘, + * 保持 system prompt 稳定以命中 KV 缓存;空结果用 "" 占位以区分「未缓存」。 + */ +@Singleton +class MemoryModule @Inject constructor( + private val memoryRepository: MemoryRepository +) : EngineModule { + + override val id = MODULE_ID + + // 注入顺序:记忆清单跟着「项目规则 / 技能」这类上下文走,用默认序即可。 + override val order = 50 + + private val cachedByKey = ConcurrentHashMap, String>() + + override fun promptFragment(ctx: EngineContext): String? { + val key = ctx.sessionId to ctx.projectRoot + val cached = cachedByKey[key] + if (cached != null) return cached.ifEmpty { null } + + val memories = try { + memoryRepository.listMemories(ctx.projectRoot) + } catch (e: Exception) { + return null + } + if (memories.isEmpty()) { + cachedByKey[key] = "" + return null + } + + val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } + val projectMemories = memories.filter { it.scope == MemoryScope.PROJECT } + + val content = buildString { + if (globalMemories.isNotEmpty()) { + append("全局记忆 (跨项目个人偏好,需要详情时用 memory(action=read, name=xxx, scope=global)):\n") + globalMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } + } + if (projectMemories.isNotEmpty()) { + if (isNotEmpty()) append("\n") + append("项目记忆 (当前项目专属,需要详情时用 memory(action=read, name=xxx, scope=project)):\n") + projectMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } + } + }.trimEnd() + + cachedByKey[key] = content + trimIfNeeded() + return content + } + + override suspend fun onSessionDeleted(ctx: EngineContext) { + cachedByKey.keys.removeAll { it.first == ctx.sessionId } + } + + private fun trimIfNeeded() { + if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() + } + + private companion object { + const val MODULE_ID = "memory" + const val SOURCE_CACHE_LIMIT = 64 + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt index 33e8b3296..1f6cce6a0 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/prompt/SystemPromptProvider.kt @@ -3,8 +3,8 @@ package com.aicode.feature.agent.domain.prompt import android.content.Context import com.aicode.core.util.FileLogger import com.aicode.feature.agent.domain.container.ContainerInstaller -import com.aicode.feature.agent.domain.memory.MemoryRepository -import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.domain.model.AgentContext import com.aicode.feature.agent.domain.skill.SkillRepository import com.aicode.feature.agent.domain.subagent.AgentDefinition @@ -31,7 +31,7 @@ import javax.inject.Singleton class SystemPromptProvider @Inject constructor( @param:ApplicationContext private val context: Context, private val skillRepository: SkillRepository, - private val memoryRepository: MemoryRepository, + private val agentEngine: AgentEngine, private val containerInstaller: ContainerInstaller, private val agentDefinitionRepository: AgentDefinitionRepository ) { @@ -182,44 +182,20 @@ class SystemPromptProvider @Inject constructor( override fun build(ctx: AgentContext): String = "[System] 当前本地时间: ${currentDate()}" } - private inner class MemoryListSource : PromptSource { - // 会话级缓存:同一 (sessionId, projectRoot) 内只读一次盘,保持 system prompt 稳定以命中 KV 缓存; - // 新开会话 / 切换工作区 / 重启 App 时缓存自然失效重建。空内容用 "" 占位以区分"未缓存"。 - private val cachedByKey = ConcurrentHashMap() - - override fun build(ctx: AgentContext): String? { - val key = SourceCacheKey(ctx.sessionId, ctx.projectRoot) - val cached = cachedByKey[key] - if (cached != null) return cached.ifEmpty { null } - val memories = try { memoryRepository.listMemories(ctx.projectRoot) } catch (e: Exception) { return null } - if (memories.isEmpty()) { - cachedByKey[key] = "" - return null - } - - val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } - val projectMemories = memories.filter { it.scope == MemoryScope.PROJECT } - - val content = buildString { - if (globalMemories.isNotEmpty()) { - append("全局记忆 (跨项目个人偏好,需要详情时用 memory(action=read, name=xxx, scope=global)):\n") - globalMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } - } - if (projectMemories.isNotEmpty()) { - if (isNotEmpty()) append("\n") - append("项目记忆 (当前项目专属,需要详情时用 memory(action=read, name=xxx, scope=project)):\n") - projectMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } - } - }.trimEnd() - - cachedByKey[key] = content - trimIfNeeded() - return content - } - - private fun trimIfNeeded() { - if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() - } + private inner class EngineFragmentSource : PromptSource { + /** + * 引擎聚合片段:由 [AgentEngine] 按模块 order 调度各模块本轮的片段。 + * 记忆清单原本在这里读盘,现已迁进 MemoryModule(连同它的会话级缓存)。 + */ + override fun build(ctx: AgentContext): String? = agentEngine.promptFragment( + EngineContext( + sessionId = ctx.sessionId, + projectRoot = ctx.projectRoot, + mode = ctx.mode, + history = ctx.history, + isSubAgent = ctx.agentDefinition != null + ) + ) } /** 会话级缓存 key:同一会话同一工作区共享一份快照,避免每轮重扫磁盘导致 system prompt 变化。 */ @@ -228,7 +204,7 @@ class SystemPromptProvider @Inject constructor( private val staticRuleSource = StaticRuleSource() private val subAgentBaseSource = SubAgentBaseSource() private val subAgentListSource = SubAgentListSource() - private val memoryListSource = MemoryListSource() + private val engineFragmentSource = EngineFragmentSource() private val activeSkillsSource = ActiveSkillsSource() private val projectRuleSource = ProjectRuleSource() private val workspaceSource = WorkspaceSource() @@ -253,7 +229,7 @@ class SystemPromptProvider @Inject constructor( val rawStatic = staticRuleSource.build(agentContext) val skillsContent = activeSkillsSource.build(agentContext) val subAgentsContent = subAgentListSource.build(agentContext) - val memoriesContent = memoryListSource.build(agentContext) + val memoriesContent = engineFragmentSource.build(agentContext) val projectRules = projectRuleSource.build(agentContext) // 2. Workspace 上下文固定输出(内容已精简,无需快照占位) @@ -310,7 +286,7 @@ class SystemPromptProvider @Inject constructor( return renderVariables( content, activeSkillsSource.build(ctx), - memoryListSource.build(ctx), + engineFragmentSource.build(ctx), subAgentListSource.build(ctx), projectRuleSource.build(ctx), workspaceSource.build(ctx), @@ -341,7 +317,7 @@ class SystemPromptProvider @Inject constructor( renderVariables( definition.prompt, activeSkillsSource.build(agentContext), - memoryListSource.build(agentContext), + engineFragmentSource.build(agentContext), subAgentListSource.build(agentContext), projectRuleSource.build(agentContext), workspaceSource.build(agentContext), @@ -356,7 +332,7 @@ class SystemPromptProvider @Inject constructor( } } if (InjectPart.MEMORY in definition.inject) { - memoryListSource.build(agentContext)?.let { + engineFragmentSource.build(agentContext)?.let { append("\n\n") append(it) } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt index 9ba54b688..7623d7313 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt @@ -1,6 +1,8 @@ package com.aicode.feature.agent.domain.session import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao import com.aicode.feature.agent.data.local.entity.ChatSessionEntity @@ -14,7 +16,8 @@ import javax.inject.Singleton @Singleton class SessionUseCase @Inject constructor( private val chatSessionDao: ChatSessionDao, - private val agentMessageDao: AgentMessageDao + private val agentMessageDao: AgentMessageDao, + private val agentEngine: AgentEngine ) { companion object { private const val TAG = "SessionUseCase" @@ -78,6 +81,10 @@ class SessionUseCase @Inject constructor( } agentMessageDao.deleteBySession(id) chatSessionDao.delete(id) + // 交给引擎分发:模块自持的会话级状态(如记忆注入缓存)在这里释放 + deleted.forEach { sessionId -> + agentEngine.onSessionDeleted(EngineContext(sessionId = sessionId)) + } return deleted } diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index 7ed45525b..27fdbc0fb 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -56,6 +56,8 @@ import com.aicode.feature.agent.domain.subagent.SubAgentEventBus import com.aicode.core.watch.FileChangeHub import com.aicode.core.watch.asDirtySignal import com.aicode.feature.agent.domain.subagent.SubAgentEventType +import com.aicode.feature.agent.domain.engine.AgentEngine +import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.domain.workflow.AgentWorkflow import com.aicode.feature.terminal.domain.TabFinishedEvent import com.aicode.feature.terminal.domain.TerminalKeepaliveService @@ -124,6 +126,7 @@ import javax.inject.Inject class AIAgentViewModel @Inject constructor( private val agentWorkflow: AgentWorkflow, private val toolRegistry: ToolRegistry, + private val agentEngine: AgentEngine, private val agentMessageDao: AgentMessageDao, private val chatSessionDao: ChatSessionDao, private val llmCallRecordDao: LlmCallRecordDao, @@ -1518,7 +1521,15 @@ class AIAgentViewModel @Inject constructor( agentDefinition = agentDefinition ) - val allTools = toolRegistry.getAvailableTools() + val allTools = (toolRegistry.getAvailableTools() + agentEngine.tools( + EngineContext( + sessionId = sessionId, + projectRoot = projectRoot, + mode = mode, + history = history, + isSubAgent = sessionEntity?.parentId != null + ) + )).distinctBy { it.name } val isSub = sessionEntity?.parentId != null val tools = when { agentDefinition != null -> { @@ -1720,6 +1731,20 @@ class AIAgentViewModel @Inject constructor( } setStreamingText(sessionId, null) + // 一轮对话正常结束:交给引擎分发,模块自行判断开关与要不要干活。 + // 放在成功路径(非 finally)——取消/报错的一轮不算「结束」,不该触发沉淀。 + if (!failed) { + agentEngine.onTurnCompleted( + EngineContext( + sessionId = sessionId, + projectRoot = projectRoot, + mode = mode, + history = history, + isSubAgent = sessionEntity?.parentId != null + ) + ) + } + } catch (e: CancellationException) { val cancelledState = _agentStates.value[sessionId] val isOwnJob = sessionJobs[sessionId] == coroutineContext[Job] diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt new file mode 100644 index 000000000..2eea03e3f --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/AgentEngineTest.kt @@ -0,0 +1,119 @@ +package com.aicode.feature.agent.domain.engine + +import com.aicode.feature.agent.domain.tool.AgentTool +import com.aicode.feature.agent.domain.tool.ToolParameter +import com.aicode.feature.agent.domain.tool.ToolResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** 引擎调度:片段聚合顺序、异常隔离、工具去重、钩子分发。 */ +class AgentEngineTest { + + private val ctx = EngineContext(sessionId = "s1", projectRoot = "/ws") + + private class FakeTool(override val name: String) : AgentTool() { + override val description = "fake" + override val parameters: Map = emptyMap() + override suspend fun execute(args: Map): ToolResult = + ToolResult.Error("unused") + } + + private class FakeModule( + override val id: String, + override val order: Int = EngineModule.DEFAULT_ORDER, + private val fragment: String? = null, + private val providedTools: List = emptyList(), + private val fragmentThrows: Boolean = false, + private val hookThrows: Boolean = false + ) : EngineModule { + var turnCompleted = 0 + var sessionDeleted = 0 + + override fun promptFragment(ctx: EngineContext): String? { + if (fragmentThrows) error("fragment boom") + return fragment + } + + override fun tools(ctx: EngineContext): List = providedTools + + override suspend fun onTurnCompleted(ctx: EngineContext) { + turnCompleted++ + if (hookThrows) error("hook boom") + } + + override suspend fun onSessionDeleted(ctx: EngineContext) { + sessionDeleted++ + } + } + + @Test + fun promptFragment_joinsByOrderAndSkipsBlank() { + val late = FakeModule(id = "late", order = 200, fragment = "B") + val early = FakeModule(id = "early", order = 10, fragment = "A") + val blank = FakeModule(id = "blank", order = 20, fragment = " ") + val silent = FakeModule(id = "silent", order = 30) + + val engine = AgentEngine(setOf(late, early, blank, silent), TestScope()) + + assertEquals("A\n\nB", engine.promptFragment(ctx)) + } + + @Test + fun promptFragment_returnsNullWhenNoModuleContributes() { + val engine = AgentEngine(setOf(FakeModule(id = "silent")), TestScope()) + assertNull(engine.promptFragment(ctx)) + } + + @Test + fun promptFragment_oneModuleThrowingDoesNotBreakOthers() { + val broken = FakeModule(id = "broken", order = 1, fragmentThrows = true) + val healthy = FakeModule(id = "healthy", order = 2, fragment = "OK") + + val engine = AgentEngine(setOf(broken, healthy), TestScope()) + + assertEquals("OK", engine.promptFragment(ctx)) + } + + @Test + fun tools_keepsFirstOccurrenceByName() { + val first = FakeModule(id = "a", order = 1, providedTools = listOf(FakeTool("dup"), FakeTool("onlyA"))) + val second = FakeModule(id = "b", order = 2, providedTools = listOf(FakeTool("dup"), FakeTool("onlyB"))) + + val engine = AgentEngine(setOf(first, second), TestScope()) + + assertEquals(listOf("dup", "onlyA", "onlyB"), engine.tools(ctx).map { it.name }) + } + + @Test + fun onTurnCompleted_dispatchesToEveryModuleAndIsolatesFailures() = runTest { + val scope = TestScope(testScheduler) + val a = FakeModule(id = "a") + val b = FakeModule(id = "b", hookThrows = true) + val c = FakeModule(id = "c") + + val engine = AgentEngine(setOf(a, b, c), scope) + engine.onTurnCompleted(ctx) + scope.advanceUntilIdle() + + assertEquals(1, a.turnCompleted) + assertEquals(1, b.turnCompleted) + assertEquals(1, c.turnCompleted) + } + + @Test + fun onSessionDeleted_dispatchesToEveryModule() = runTest { + val scope = TestScope(testScheduler) + val module = FakeModule(id = "a") + + val engine = AgentEngine(setOf(module), scope) + engine.onSessionDeleted(ctx) + scope.advanceUntilIdle() + + assertEquals(1, module.sessionDeleted) + } +} diff --git a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt index c08535b92..61b8986c6 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt @@ -3,9 +3,13 @@ package com.aicode.feature.agent.domain.session import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao import com.aicode.feature.agent.data.local.entity.ChatSessionEntity +import com.aicode.feature.agent.domain.engine.AgentEngine import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -13,6 +17,9 @@ import org.junit.Test /** 删除工作区时按 workspacePath 级联清理会话与消息的行为。 */ class SessionUseCaseWorkspaceDeletionTest { + /** 空模块集的引擎:本测试只验证会话/消息删除,引擎侧钩子无副作用。 */ + private fun emptyEngine() = AgentEngine(emptySet(), CoroutineScope(SupervisorJob() + Dispatchers.IO)) + private fun session(id: String, workspacePath: String, parentId: String? = null) = ChatSessionEntity( id = id, title = "t", @@ -31,7 +38,7 @@ class SessionUseCaseWorkspaceDeletionTest { session("sub", "/ws/a", parentId = "root") ) - val useCase = SessionUseCase(chatDao, messageDao) + val useCase = SessionUseCase(chatDao, messageDao, emptyEngine()) val deleted = useCase.deleteSessionsByWorkspace("/ws/a") assertEquals(2, deleted) @@ -46,7 +53,7 @@ class SessionUseCaseWorkspaceDeletionTest { val messageDao = mockk(relaxed = true) coEvery { chatDao.getAllSessionsByWorkspaceOnce("/ws/empty") } returns emptyList() - val useCase = SessionUseCase(chatDao, messageDao) + val useCase = SessionUseCase(chatDao, messageDao, emptyEngine()) val deleted = useCase.deleteSessionsByWorkspace("/ws/empty") assertEquals(0, deleted) From 205858f31daecfee9012c1ac72cac59466621731 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:31:09 +0000 Subject: [PATCH 04/14] =?UTF-8?q?fix(engine):=20=E7=94=A8=20Lazy=20?= =?UTF-8?q?=E6=96=AD=E5=BC=80=20Dagger=20=E4=BE=9D=E8=B5=96=E7=8E=AF?= =?UTF-8?q?=EF=BC=88CI=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionUseCase → AgentEngine → MemoryModule → MemoryRepository → ProjectAicodeRoot → WorkspaceRepository → SessionUseCase,构成依赖环,Hilt 编译报 DependencyCycle。 钩子只在删除会话时用一次,SessionUseCase 改为注入 dagger.Lazy, 延迟取用即断开环;同步修单测构造(Lazy { AgentEngine(emptySet(), scope) })。 --- .../aicode/feature/agent/domain/session/SessionUseCase.kt | 7 +++++-- .../domain/session/SessionUseCaseWorkspaceDeletionTest.kt | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt index 7623d7313..dfb39868d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/session/SessionUseCase.kt @@ -9,6 +9,7 @@ import com.aicode.feature.agent.data.local.entity.ChatSessionEntity import com.aicode.feature.agent.domain.model.AgentMode import com.aicode.feature.agent.domain.model.ReasoningEffort import com.aicode.feature.agent.presentation.MessageRole +import dagger.Lazy import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -17,7 +18,9 @@ import javax.inject.Singleton class SessionUseCase @Inject constructor( private val chatSessionDao: ChatSessionDao, private val agentMessageDao: AgentMessageDao, - private val agentEngine: AgentEngine + // Lazy 断环:引擎 → 记忆模块 → MemoryRepository → ProjectAicodeRoot → WorkspaceRepository → 本类, + // 直接注入会形成 Dagger 依赖环;钩子只在删除时用一次,延迟取即可。 + private val agentEngine: Lazy ) { companion object { private const val TAG = "SessionUseCase" @@ -83,7 +86,7 @@ class SessionUseCase @Inject constructor( chatSessionDao.delete(id) // 交给引擎分发:模块自持的会话级状态(如记忆注入缓存)在这里释放 deleted.forEach { sessionId -> - agentEngine.onSessionDeleted(EngineContext(sessionId = sessionId)) + agentEngine.get().onSessionDeleted(EngineContext(sessionId = sessionId)) } return deleted } diff --git a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt index 61b8986c6..a6b3538dc 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/session/SessionUseCaseWorkspaceDeletionTest.kt @@ -4,6 +4,7 @@ import com.aicode.feature.agent.data.local.dao.AgentMessageDao import com.aicode.feature.agent.data.local.dao.ChatSessionDao import com.aicode.feature.agent.data.local.entity.ChatSessionEntity import com.aicode.feature.agent.domain.engine.AgentEngine +import dagger.Lazy import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -18,7 +19,8 @@ import org.junit.Test class SessionUseCaseWorkspaceDeletionTest { /** 空模块集的引擎:本测试只验证会话/消息删除,引擎侧钩子无副作用。 */ - private fun emptyEngine() = AgentEngine(emptySet(), CoroutineScope(SupervisorJob() + Dispatchers.IO)) + private fun emptyEngine(): Lazy = + Lazy { AgentEngine(emptySet(), CoroutineScope(SupervisorJob() + Dispatchers.IO)) } private fun session(id: String, workspacePath: String, parentId: String? = null) = ChatSessionEntity( id = id, From 21a80926667972c1911730ff9e0f986caa0d694a Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:31:56 +0000 Subject: [PATCH 05/14] =?UTF-8?q?feat(memory):=20=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E8=87=AA=E5=8A=A8=E6=B2=89=E6=B7=80=EF=BC=88?= =?UTF-8?q?=E5=BC=95=E6=93=8E=20onTurnCompleted=20=E7=9A=84=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E7=9C=9F=E5=AE=9E=E7=94=A8=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要的:开关自己控制、触发时机固定、过程可感知,不是黑盒。 - MemorySettingsRepository:记忆模块自己的开关(DataStore memory_prefs), 长期记忆自动沉淀默认关——装完不会在后台默默记东西 - assets/prompts/agent/memory-distiller.md:归纳提示词,只记关于用户的稳定结论, 输出 JSON 数组(名称/一句话描述/正文),无事可记输出 [],同名视为取代旧结论 - MemoryModule.onTurnCompleted:每轮正常结束后,开关打开且非子代理会话时, 拿已有长期记忆 + 本轮对话(截断)问一次模型,解析后按 kind=PROFILE 写入全局记忆, 写完丢掉本会话注入缓存(下一轮注入即带上新内容);解析失败/空结果静默跳过 - EngineContext 加 oneShot 回调:模型调用能力由会话运行侧递进模块, 避免 模块→workflow→SystemPromptProvider→AgentEngine→模块 的 DI 环 - AgentWorkflow.oneShotComplete:独立 provider 的一次性调用,不占主对话、不写会话消息, 用量落 llm_call_records(kind=memory-distill) - 记忆页顶部加「长期记忆自动沉淀」开关行(AppSwitch,中英同步) 测试:MemoryModuleTest 4 例(正常写入 / 开关关不写 / 子代理不写 / 垃圾输出与空结果不写) --- .../assets/prompts/agent/memory-distiller.md | 21 ++++ .../agent/domain/engine/EngineModule.kt | 10 +- .../domain/engine/modules/MemoryModule.kt | 104 ++++++++++++++++- .../agent/domain/workflow/AgentWorkflow.kt | 16 +++ .../domain/workflow/StatefulAgentWorkflow.kt | 60 ++++++++++ .../agent/presentation/AIAgentViewModel.kt | 6 +- .../repository/MemorySettingsRepository.kt | 40 +++++++ .../settings/presentation/MemoryViewModel.kt | 20 +++- .../presentation/component/MemorySection.kt | 105 ++++++++++-------- .../presentation/component/SettingsScreen.kt | 3 + app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../domain/engine/modules/MemoryModuleTest.kt | 84 ++++++++++++++ 13 files changed, 421 insertions(+), 52 deletions(-) create mode 100644 app/src/main/assets/prompts/agent/memory-distiller.md create mode 100644 app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt create mode 100644 app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt diff --git a/app/src/main/assets/prompts/agent/memory-distiller.md b/app/src/main/assets/prompts/agent/memory-distiller.md new file mode 100644 index 000000000..e314418d6 --- /dev/null +++ b/app/src/main/assets/prompts/agent/memory-distiller.md @@ -0,0 +1,21 @@ + +You extract long-term memory about the user from a conversation transcript. Output ONLY a JSON array. + + +Read the transcript and the existing memory list. Decide whether anything worth remembering long-term appeared in this turn. + + + +- Record only stable, reusable facts about the user: preferences, working habits, communication style, tech stack, environment constraints, recurring corrections, project conventions. +- Never record: one-off task details, transient state, tool output, code snippets, or anything that will be stale tomorrow. +- Never duplicate an existing memory with the same meaning. If the transcript corrects or extends one, reuse the SAME "name" so the new entry replaces the old one. +- Write the values in the same language the user writes in. +- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates. +- "description": one line, at most 60 characters — it is injected into the system prompt as the summary. +- "content": one to three short lines of detail, no headings, no bullet markers. +- Output [] when nothing qualifies. Output raw JSON only: no prose, no code fences, no trailing text. + + + +[{"name":"prefers-concise-answers","description":"Prefers short answers, no filler","content":"Dislikes long preambles. Wants conclusions first."}] + diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt index a5abecc76..5146ec9cd 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt @@ -17,7 +17,15 @@ data class EngineContext( val mode: AgentMode = AgentMode.BUILD, val history: List = emptyList(), /** 是否为子代理会话(按自定义子代理定义运行)。 */ - val isSubAgent: Boolean = false + val isSubAgent: Boolean = false, + /** + * 一次性模型调用能力,由会话运行侧注入(不占主对话 provider、不写入会话消息)。 + * 模块拿它做归纳类工作;null 表示当前不可用(如未配置模型)。 + * + * 用回调而不是让模块注入 workflow:模块 → workflow → SystemPromptProvider → + * AgentEngine → 模块 会形成 DI 环,所以能力只能从调用方递进来。 + */ + val oneShot: (suspend (promptFile: String, userPrompt: String) -> String?)? = null ) /** diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 022cefafa..5ecb75e9f 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -1,9 +1,17 @@ package com.aicode.feature.agent.domain.engine.modules +import com.aicode.core.util.FileLogger import com.aicode.feature.agent.domain.engine.EngineContext import com.aicode.feature.agent.domain.engine.EngineModule +import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryKind import com.aicode.feature.agent.domain.memory.MemoryRepository import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.settings.data.repository.MemorySettingsRepository +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -11,15 +19,17 @@ import javax.inject.Singleton /** * 记忆模块:把「AI 记住了什么」接进引擎。 * - * 目前只负责注入——把记忆清单(名称 + 描述)拼进系统提示词,详情仍由模型调 - * `memory(action=read)` 自取;自动沉淀等能力后续接在同一模块内。 + * 两件事: + * 1. 注入——把记忆清单(名称 + 描述)拼进系统提示词,详情仍由模型调 `memory(action=read)` 自取; + * 2. 沉淀——每轮对话结束后(开关打开时)归纳出关于用户的稳定结论,写进长期记忆。 * * 缓存策略沿用迁移前的实现:按 (sessionId, projectRoot) 会话级缓存,同一会话内只读一次盘, * 保持 system prompt 稳定以命中 KV 缓存;空结果用 "" 占位以区分「未缓存」。 */ @Singleton class MemoryModule @Inject constructor( - private val memoryRepository: MemoryRepository + private val memoryRepository: MemoryRepository, + private val memorySettings: MemorySettingsRepository ) : EngineModule { override val id = MODULE_ID @@ -64,16 +74,104 @@ class MemoryModule @Inject constructor( return content } + /** + * 每轮对话结束后沉淀长期记忆。 + * + * 只有开关打开、非子代理会话、且调用方递进了一次性模型调用能力时才干活; + * 归纳结果解析失败或模型返回空数组都算「这轮没什么可记的」,静默跳过。 + */ + override suspend fun onTurnCompleted(ctx: EngineContext) { + if (ctx.isSubAgent) return + if (ctx.history.isEmpty()) return + val complete = ctx.oneShot ?: return + if (!memorySettings.autoDistillEnabled()) return + + val existing = runCatching { memoryRepository.listMemories(ctx.projectRoot) } + .getOrDefault(emptyList()) + .filter { it.kind == MemoryKind.PROFILE } + + val raw = complete(PROMPT_FILE, buildUserPrompt(ctx, existing)) ?: return + val entries = parseEntries(raw) + if (entries.isEmpty()) return + + entries.forEach { entry -> + // 沉淀出来的结论都是「关于用户」的,跨项目通用,因此统一写全局。 + memoryRepository.saveMemory( + name = entry.name, + description = entry.description, + content = entry.content, + scope = MemoryScope.GLOBAL, + projectRoot = ctx.projectRoot, + kind = MemoryKind.PROFILE + ) + } + FileLogger.i(TAG, "沉淀长期记忆 ${entries.size} 条: ${entries.joinToString { it.name }}") + // 本轮新增/更新了记忆 → 丢掉本会话的注入缓存,下一轮注入就带上新内容 + cachedByKey.remove(ctx.sessionId to ctx.projectRoot) + } + override suspend fun onSessionDeleted(ctx: EngineContext) { cachedByKey.keys.removeAll { it.first == ctx.sessionId } } + /** 交给模型的输入:已有长期记忆(防重复)+ 本轮对话(截断,控制 token)。 */ + private fun buildUserPrompt(ctx: EngineContext, existing: List): String = + buildString { + appendLine("Existing long-term memories (same name = replaces the old entry):") + if (existing.isEmpty()) { + appendLine("(none)") + } else { + existing.forEach { appendLine("- ${it.name}: ${it.description}") } + } + appendLine() + appendLine("Transcript of the latest turn:") + ctx.history.takeLast(TRANSCRIPT_MESSAGES).forEach { appendLine(renderMessage(it)) } + } + + private fun renderMessage(message: AgentMessage): String = when (message) { + is AgentMessage.UserMessage -> "user: ${message.content.take(MAX_MESSAGE_CHARS)}" + is AgentMessage.AssistantMessage -> "assistant: ${message.content.take(MAX_MESSAGE_CHARS)}" + is AgentMessage.ToolResultMessage -> + "tool(${message.toolName}): ${(message.modelResult ?: message.result).take(MAX_TOOL_CHARS)}" + } + + /** 解析模型输出:容错地取出 JSON 数组段,容忍 ``` 包裹与前后废话。 */ + private fun parseEntries(raw: String): List { + val start = raw.indexOf('[') + val end = raw.lastIndexOf(']') + if (start < 0 || end <= start) return emptyList() + return runCatching { + Json { ignoreUnknownKeys = true } + .decodeFromString(ListSerializer(DistilledEntry.serializer()), raw.substring(start, end + 1)) + .filter { it.name.isNotBlank() && it.content.isNotBlank() } + .take(MAX_ENTRIES_PER_TURN) + }.onFailure { + FileLogger.w(TAG, "沉淀结果解析失败,已跳过本轮", it) + }.getOrDefault(emptyList()) + } + private fun trimIfNeeded() { if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() } + @Serializable + private data class DistilledEntry( + val name: String, + val description: String = "", + val content: String = "" + ) + private companion object { const val MODULE_ID = "memory" + const val TAG = "MemoryModule" const val SOURCE_CACHE_LIMIT = 64 + + /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */ + const val PROMPT_FILE = "agent/memory-distiller.md" + + const val TRANSCRIPT_MESSAGES = 12 + const val MAX_MESSAGE_CHARS = 1200 + const val MAX_TOOL_CHARS = 400 + const val MAX_ENTRIES_PER_TURN = 5 } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt index 0b560b1dd..fe2980b3d 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/AgentWorkflow.kt @@ -121,4 +121,20 @@ interface AgentWorkflow { * @return 建议的提交说明,失败返回 null。 */ suspend fun generateCommitMessage(diff: String): String? + + /** + * 用指定提示词资产文件做一次独立模型调用(不占主对话 provider、不写入会话消息), + * 供引擎模块做归纳类工作(如长期记忆沉淀)。 + * + * @param promptFile `prompts/` 下的相对路径,如 `agent/memory-distiller.md` + * @param userPrompt 本次要处理的用户消息内容 + * @param kind 调用记录类型(写进 llm_call_records,便于统计区分) + * @return 模型输出文本;未配置可用模型或调用失败时返回 null。 + */ + suspend fun oneShotComplete( + sessionId: String?, + promptFile: String, + userPrompt: String, + kind: String + ): String? } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt index 0051dd087..028a0b357 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt @@ -996,6 +996,66 @@ class StatefulAgentWorkflow @Inject constructor( FileLogger.w(TAG, "生成提交信息失败", e) }.getOrNull() + /** + * 一次性独立模型调用:主聊天模型(拿不到时回退标题专用模型),不占主对话 provider、 + * 不写入会话消息,只把用量落进 llm_call_records(kind 由调用方给)。 + */ + override suspend fun oneShotComplete( + sessionId: String?, + promptFile: String, + userPrompt: String, + kind: String + ): String? = runCatching { + val provider = getEffectiveProvider(sessionId) + val prompt = promptProvider.resolvePrompt(promptFile).replace(LEADING_COMMENT, "") + val callStartWall = System.currentTimeMillis() + val callStartElapsed = SystemClock.elapsedRealtime() + var callCompleted = false + var callError: String? = null + var usage: AIResponse? = null + val response = try { + val resp = provider.complete( + systemPrompt = prompt, + messages = listOf(AgentMessage.UserMessage(content = userPrompt)), + tools = emptyList() + ) + usage = resp + callCompleted = true + resp + } catch (e: CancellationException) { + callError = "cancelled" + throw e + } catch (e: Exception) { + callError = e.message ?: e.javaClass.simpleName + throw e + } finally { + val durationMillis = (SystemClock.elapsedRealtime() - callStartElapsed).toInt() + runCatching { + llmCallRecordDao.insert( + LlmCallRecordEntity( + sessionId = sessionId, + providerId = provider.providerId.ifBlank { null }, + model = provider.model, + kind = kind, + inputTokens = usage?.inputTokens ?: 0, + outputTokens = usage?.outputTokens ?: 0, + cachedInputTokens = usage?.cachedInputTokens ?: 0, + cacheCreationTokens = usage?.cacheCreationTokens ?: 0, + ttfbMillis = null, + durationMillis = durationMillis, + status = if (callCompleted) "success" else "error", + errorMessage = callError, + stopReason = usage?.stopReason, + createdAt = callStartWall + ) + ) + } + } + response.content.trim().ifBlank { null } + }.onFailure { e -> + FileLogger.w(TAG, "一次性模型调用失败: $promptFile", e) + }.getOrNull() + private suspend fun runToolStream( tool: StreamingAgentTool, toolCall: ToolCall, diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index 27fdbc0fb..5f29e82e6 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -1740,7 +1740,11 @@ class AIAgentViewModel @Inject constructor( projectRoot = projectRoot, mode = mode, history = history, - isSubAgent = sessionEntity?.parentId != null + isSubAgent = sessionEntity?.parentId != null, + // 模块做归纳时的一次性模型调用:走独立 provider,不占主对话 + oneShot = { promptFile, userPrompt -> + agentWorkflow.oneShotComplete(sessionId, promptFile, userPrompt, "memory-distill") + } ) ) } diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt new file mode 100644 index 000000000..57f64976c --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt @@ -0,0 +1,40 @@ +package com.aicode.feature.settings.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.memoryDataStore by preferencesDataStore(name = "memory_prefs") + +/** + * 记忆模块自己的开关(每个引擎模块一套开关,互不影响)。 + * + * 目前一项:长期记忆自动沉淀。 + * **默认关**是刻意的——自动沉淀会调模型、写文件,用户必须自己打开才知道它在工作, + * 而不是装完就在后台默默记东西(这正是上一版画像被否掉的原因)。 + */ +@Singleton +class MemorySettingsRepository @Inject constructor( + @param:ApplicationContext private val context: Context +) { + + val autoDistillEnabledFlow: Flow = context.memoryDataStore.data + .map { it[AUTO_DISTILL_ENABLED_KEY] ?: false } + + suspend fun autoDistillEnabled(): Boolean = autoDistillEnabledFlow.first() + + suspend fun setAutoDistillEnabled(enabled: Boolean) { + context.memoryDataStore.edit { it[AUTO_DISTILL_ENABLED_KEY] = enabled } + } + + private companion object { + val AUTO_DISTILL_ENABLED_KEY = booleanPreferencesKey("auto_distill_enabled") + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt index dd37f4e47..1e1eb4e18 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt @@ -4,30 +4,38 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aicode.feature.agent.domain.memory.Memory import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.settings.data.repository.MemorySettingsRepository import com.aicode.feature.workspace.data.repository.WorkspaceRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject /** - * 记忆页状态:列出当前生效的记忆(全局与项目由 Repository 合并去重,界面不再分栏),支持删除。 + * 记忆页状态:列出当前生效的记忆(全局与项目由 Repository 合并去重,界面不再分栏), + * 支持删除,并提供「长期记忆自动沉淀」开关。 * - * 扫描的是磁盘上的记忆文件,同为 IO,故与删除一起放到 IO 线程。 + * 扫描磁盘记忆文件与删除都是 IO,统一放 IO 线程。 */ @HiltViewModel class MemoryViewModel @Inject constructor( private val memoryRepository: MemoryRepository, - private val workspaceRepository: WorkspaceRepository + private val workspaceRepository: WorkspaceRepository, + private val memorySettings: MemorySettingsRepository ) : ViewModel() { private val _memories = MutableStateFlow>(emptyList()) val memories: StateFlow> = _memories.asStateFlow() + val autoDistillEnabled: StateFlow = memorySettings.autoDistillEnabledFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + init { refresh() } @@ -41,6 +49,12 @@ class MemoryViewModel @Inject constructor( } } + fun setAutoDistillEnabled(enabled: Boolean) { + viewModelScope.launch { + memorySettings.setAutoDistillEnabled(enabled) + } + } + fun delete(memory: Memory) { viewModelScope.launch { val projectRoot = workspaceRepository.currentPath() diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt index 02ec87e34..4171e0937 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt @@ -29,13 +29,14 @@ import com.aicode.R import com.aicode.core.theme.Radius import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.SwipeToDeleteRow import com.aicode.feature.agent.domain.memory.Memory import compose.icons.FeatherIcons import compose.icons.feathericons.FileText /** - * 记忆页:列出当前生效的记忆,左滑删除。 + * 记忆页:长期记忆自动沉淀开关 + 当前生效的记忆列表(左滑删除)。 * * 全局与项目记忆合并成一份清单、不分栏——用户看到的只是「AI 记住了什么」, * 记忆存在哪一侧是实现细节。 @@ -43,48 +44,10 @@ import compose.icons.feathericons.FileText @Composable internal fun MemorySection( memories: List, + autoDistillEnabled: Boolean, + onToggleAutoDistill: (Boolean) -> Unit, onDelete: (Memory) -> Unit ) { - if (memories.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = Spacing.xl, vertical = 48.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md) - ) { - Box( - modifier = Modifier - .size(64.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.lg)), - contentAlignment = Alignment.Center - ) { - Icon( - FeatherIcons.FileText, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Text( - text = stringResource(R.string.memory_empty), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.memory_empty_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } - return - } - Column( modifier = Modifier .fillMaxSize() @@ -94,14 +57,68 @@ internal fun MemorySection( verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { SettingsGroup { - memories.forEachIndexed { index, memory -> - if (index > 0) SettingsDivider() - MemoryRow(memory = memory, onDelete = { onDelete(memory) }) + SettingsRow( + icon = null, + title = stringResource(R.string.memory_auto_distill), + subtitle = stringResource(R.string.memory_auto_distill_desc), + trailing = { + AppSwitch( + checked = autoDistillEnabled, + onCheckedChange = onToggleAutoDistill + ) + } + ) + } + + if (memories.isEmpty()) { + EmptyState() + } else { + SettingsGroup { + memories.forEachIndexed { index, memory -> + if (index > 0) SettingsDivider() + MemoryRow(memory = memory, onDelete = { onDelete(memory) }) + } } } } } +@Composable +private fun EmptyState() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.lg)), + contentAlignment = Alignment.Center + ) { + Icon( + FeatherIcons.FileText, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.memory_empty), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.memory_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + /** 单条记忆行:图标 + 名称/描述,左滑删除。 */ @Composable private fun MemoryRow( diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index 1d99208ee..47b38b3bd 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -828,8 +828,11 @@ fun SettingsScreen( val memoryViewModel: com.aicode.feature.settings.presentation.MemoryViewModel = androidx.hilt.navigation.compose.hiltViewModel() val memories by memoryViewModel.memories.collectAsStateWithLifecycle() + val autoDistill by memoryViewModel.autoDistillEnabled.collectAsStateWithLifecycle() MemorySection( memories = memories, + autoDistillEnabled = autoDistill, + onToggleAutoDistill = memoryViewModel::setAutoDistillEnabled, onDelete = memoryViewModel::delete ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 09a619ef0..43bcf40a5 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -969,6 +969,8 @@ No memories yet Whatever you ask the assistant to remember shows up here + Auto-save long-term memory + After each turn, distill stable conclusions about you into memory (uses the model; off by default) No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce2adc59..86355412e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -969,6 +969,8 @@ 还没有记忆 对话中让 AI 记住的内容会显示在这里 + 长期记忆自动沉淀 + 每轮对话结束后归纳关于你的结论并写入记忆(会调用模型,默认关闭) 还没有安装技能 diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt new file mode 100644 index 000000000..856d8431d --- /dev/null +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt @@ -0,0 +1,84 @@ +package com.aicode.feature.agent.domain.engine.modules + +import com.aicode.feature.agent.domain.engine.EngineContext +import com.aicode.feature.agent.domain.memory.MemoryKind +import com.aicode.feature.agent.domain.memory.MemoryRepository +import com.aicode.feature.agent.domain.memory.MemoryScope +import com.aicode.feature.agent.domain.model.AgentMessage +import com.aicode.feature.settings.data.repository.MemorySettingsRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** 记忆模块的沉淀行为:开关、子代理、解析容错。 */ +class MemoryModuleTest { + + private val history = listOf(AgentMessage.UserMessage(content = "以后回答短一点")) + + private fun module(settingsEnabled: Boolean): Pair { + val repository = mockk(relaxed = true) + val settings = mockk() + coEvery { settings.autoDistillEnabled() } returns settingsEnabled + every { repository.listMemories(any()) } returns emptyList() + return MemoryModule(repository, settings) to repository + } + + private fun ctx(oneShot: (suspend (String, String) -> String?)?) = EngineContext( + sessionId = "s1", + projectRoot = "/ws", + history = history, + oneShot = oneShot + ) + + @Test + fun onTurnCompleted_writesDistilledEntriesAsProfile() = runTest { + val (module, repository) = module(settingsEnabled = true) + val json = """[{"name":"prefers-brief","description":"Prefers brief answers","content":"No preambles."}]""" + + module.onTurnCompleted(ctx { _, _ -> json }) + + coVerify(exactly = 1) { + repository.saveMemory( + "prefers-brief", + "Prefers brief answers", + "No preambles.", + MemoryScope.GLOBAL, + "/ws", + MemoryKind.PROFILE + ) + } + } + + @Test + fun onTurnCompleted_doesNothingWhenSwitchOff() = runTest { + val (module, repository) = module(settingsEnabled = false) + + module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun onTurnCompleted_doesNothingForSubAgentSession() = runTest { + val (module, repository) = module(settingsEnabled = true) + val subCtx = ctx { _, _ -> """[{"name":"a","content":"b"}]""" }.copy(isSubAgent = true) + + module.onTurnCompleted(subCtx) + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun onTurnCompleted_ignoresGarbageAndEmptyResults() = runTest { + val (module, repository) = module(settingsEnabled = true) + + module.onTurnCompleted(ctx { _, _ -> "抱歉,这轮没有值得记住的内容。" }) + module.onTurnCompleted(ctx { _, _ -> "[]" }) + module.onTurnCompleted(ctx { _, _ -> null }) + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } +} From 956432af7d6f6d1a28c15be10cc05e788f03af89 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:54:08 +0000 Subject: [PATCH 06/14] =?UTF-8?q?fix(memory):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=B2=89=E6=B7=80=E6=B2=A1=E7=94=9F=E6=95=88=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E5=8E=9F=E5=9B=A0=E2=80=94=E2=80=94=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E8=90=BD=E5=90=8E=E4=B8=80=E8=BD=AE=E3=80=81?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E9=A1=B5=E4=B8=8D=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:打开开关、聊了几轮,记忆里什么都没出现。 根因一(沉淀看不到内容): AIAgentViewModel 里传给引擎的 history 是「本轮开始前」的快照(在插入本次用户消息 之前读的,为了给 workflow 当上下文)。沉淀模块拿到的因此永远慢一轮, 新建会话的第一轮更是空列表直接跳过。改为触发时重新读一次历史。 根因二(写进去了也看不到): MemoryViewModel 只在 init 扫盘,而 VM 在设置页返回栈里常驻——用户先打开记忆页 开开关、再去聊完回来,列表还是旧快照。改为每次进入记忆分区都重扫。 两处都不改沉淀本身的逻辑与开关语义。 --- .../aicode/feature/agent/presentation/AIAgentViewModel.kt | 7 ++++++- .../settings/presentation/component/SettingsScreen.kt | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt index 5f29e82e6..38b0bb352 100644 --- a/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt +++ b/app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt @@ -1734,12 +1734,17 @@ class AIAgentViewModel @Inject constructor( // 一轮对话正常结束:交给引擎分发,模块自行判断开关与要不要干活。 // 放在成功路径(非 finally)——取消/报错的一轮不算「结束」,不该触发沉淀。 if (!failed) { + // 重新读一次历史:上面那份 history 是「本轮开始前」的快照,不含刚结束的这一轮, + // 沉淀模块需要看到本轮内容(否则永远慢一轮,首轮更是空的)。 + val turnHistory = runCatching { + messagePersistenceUseCase.buildHistory(sessionId, SessionUseCase.PENDING_TOOL_MARKER) + }.getOrDefault(emptyList()) agentEngine.onTurnCompleted( EngineContext( sessionId = sessionId, projectRoot = projectRoot, mode = mode, - history = history, + history = turnHistory, isSubAgent = sessionEntity?.parentId != null, // 模块做归纳时的一次性模型调用:走独立 provider,不占主对话 oneShot = { promptFile, userPrompt -> diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index 47b38b3bd..a6aa613d3 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -829,6 +829,9 @@ fun SettingsScreen( androidx.hilt.navigation.compose.hiltViewModel() val memories by memoryViewModel.memories.collectAsStateWithLifecycle() val autoDistill by memoryViewModel.autoDistillEnabled.collectAsStateWithLifecycle() + // 每次进入本分区重扫:引擎可能在后台刚沉淀了新条目, + // 而 VM 在设置页返回栈里常驻,只在 init 扫一次会永远是旧列表。 + LaunchedEffect(Unit) { memoryViewModel.refresh() } MemorySection( memories = memories, autoDistillEnabled = autoDistill, From b66c45863d684f9642ebbf0db52ec30e4484e16b Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:10:28 +0000 Subject: [PATCH 07/14] =?UTF-8?q?feat(memory):=20=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=20+=20=E6=B2=89=E6=B7=80=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E6=94=B9=E4=B8=BA=E8=AF=AD=E4=B9=89=E5=88=A4?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈两条: 1. 识别率不够高; 2. 希望能点击查看记忆详情。 识别率问题出在提示词设计上:上一版列的是关键词清单(「以后…」「别…」「我要…」), 模型会照着字面找,没命中就漏——而偏好大多是隐含的,用户很少明说「记住这个」。 改为语义判据,不依赖任何关键词: - 定一条判据:「如果在未来会话开始时就知道这条,我会不会做法不同?」会 → 记 - 明确要求从行为推断信号:反复纠正、挑选/否决选项、表达不耐烦或认可、环境限制、 项目构建方式、重复出现的术语路径、要求的输出形态、透露的身份与语言 - 例子也换成隐含信号(用户重复同一要求 → 记),不再给关键词式例子 - 转录窗口 12 → 20 条消息,跨轮信号更容易被看到 记忆详情:点行弹出底部面板(AdaptiveModalBottomSheet), 显示名称、来源(自动沉淀 / 对话中记录)、描述与完整正文(正文平时注入看不到,详情才展开)。 中英文案各 4 条。 --- .../assets/prompts/agent/memory-distiller.md | 42 +++++++++++---- .../domain/engine/modules/MemoryModule.kt | 2 +- .../presentation/component/MemorySection.kt | 51 +++++++++++++++++-- .../presentation/component/SettingsScreen.kt | 5 ++ app/src/main/res/values-en/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 6 files changed, 95 insertions(+), 13 deletions(-) diff --git a/app/src/main/assets/prompts/agent/memory-distiller.md b/app/src/main/assets/prompts/agent/memory-distiller.md index e314418d6..325b44bb0 100644 --- a/app/src/main/assets/prompts/agent/memory-distiller.md +++ b/app/src/main/assets/prompts/agent/memory-distiller.md @@ -1,21 +1,45 @@ -You extract long-term memory about the user from a conversation transcript. Output ONLY a JSON array. +You maintain long-term memory about the user. Output ONLY a JSON array. -Read the transcript and the existing memory list. Decide whether anything worth remembering long-term appeared in this turn. +Read the transcript and the existing memory list, then decide which facts about the user are worth carrying into future sessions. + +For every piece of information you notice, apply this test: + + "If I knew this at the start of a future session, would I behave differently?" + +- Yes → record it. +- No, or it only matters for the task at hand → skip it. + +Do NOT scan for keywords or phrases. The user will almost never announce a preference explicitly — +most of what matters has to be INFERRED from what they do. Signals include: +- they correct you, or repeat an instruction because you missed it the first time +- they reject, or pick among, options you offered +- they express approval, frustration or impatience about how you work +- they state a constraint about their environment: device, network, permissions, tooling, what fails +- they explain how their project is built, named, tested, reviewed or deployed +- they keep using the same term, path, command or technology +- they ask for a particular output shape: length, language, format, level of detail +- they reveal something about themselves: role, expertise, language, time zone, what they care about + + -- Record only stable, reusable facts about the user: preferences, working habits, communication style, tech stack, environment constraints, recurring corrections, project conventions. -- Never record: one-off task details, transient state, tool output, code snippets, or anything that will be stale tomorrow. +- Prefer recording over skipping: a missing memory is worse than an extra one, and the user can delete entries in the app. +- Record the user's durable traits, not the task. Skip one-off task details, transient state, tool output, code snippets, or anything stale tomorrow. +- Write each entry as a fact about the user, not as a summary of what happened in the conversation. - Never duplicate an existing memory with the same meaning. If the transcript corrects or extends one, reuse the SAME "name" so the new entry replaces the old one. +- A turn may yield zero, one, or several entries — do not force one, but do not miss an implicit preference. - Write the values in the same language the user writes in. -- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates. +- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates of the same fact. - "description": one line, at most 60 characters — it is injected into the system prompt as the summary. - "content": one to three short lines of detail, no headings, no bullet markers. -- Output [] when nothing qualifies. Output raw JSON only: no prose, no code fences, no trailing text. +- Output [] only when the transcript really contains nothing durable. Output raw JSON only: no prose, no code fences, no trailing text. - -[{"name":"prefers-concise-answers","description":"Prefers short answers, no filler","content":"Dislikes long preambles. Wants conclusions first."}] - + +User repeats a request they already made twice → [{"name":"needs-explicit-confirmation","description":"Repeats instructions; confirm before acting","content":"Has had to repeat the same instruction more than once — restate the plan and confirm before starting."}] +User pastes a build error from their own machine → [{"name":"local-build-environment","description":"Builds locally and hits environment errors","content":"Runs builds on their own machine; container-side fixes must account for their local toolchain."}] +User: "帮我看看这个函数" (nothing else) → [] + diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 5ecb75e9f..857d59b2a 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -169,7 +169,7 @@ class MemoryModule @Inject constructor( /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */ const val PROMPT_FILE = "agent/memory-distiller.md" - const val TRANSCRIPT_MESSAGES = 12 + const val TRANSCRIPT_MESSAGES = 20 const val MAX_MESSAGE_CHARS = 1200 const val MAX_TOOL_CHARS = 400 const val MAX_ENTRIES_PER_TURN = 5 diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt index 4171e0937..1ec47e918 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt @@ -29,9 +29,11 @@ import com.aicode.R import com.aicode.core.theme.Radius import com.aicode.core.theme.Spacing import com.aicode.core.theme.semanticColors +import com.aicode.core.ui.AdaptiveModalBottomSheet import com.aicode.core.ui.AppSwitch import com.aicode.core.ui.SwipeToDeleteRow import com.aicode.feature.agent.domain.memory.Memory +import com.aicode.feature.agent.domain.memory.MemoryKind import compose.icons.FeatherIcons import compose.icons.feathericons.FileText @@ -46,6 +48,7 @@ internal fun MemorySection( memories: List, autoDistillEnabled: Boolean, onToggleAutoDistill: (Boolean) -> Unit, + onOpenDetail: (Memory) -> Unit, onDelete: (Memory) -> Unit ) { Column( @@ -76,7 +79,7 @@ internal fun MemorySection( SettingsGroup { memories.forEachIndexed { index, memory -> if (index > 0) SettingsDivider() - MemoryRow(memory = memory, onDelete = { onDelete(memory) }) + MemoryRow(memory = memory, onOpenDetail = { onOpenDetail(memory) }, onDelete = { onDelete(memory) }) } } } @@ -119,15 +122,16 @@ private fun EmptyState() { } } -/** 单条记忆行:图标 + 名称/描述,左滑删除。 */ +/** 单条记忆行:图标 + 名称/描述,点击看详情,左滑删除。 */ @Composable private fun MemoryRow( memory: Memory, + onOpenDetail: () -> Unit, onDelete: () -> Unit ) { val rowBackground = MaterialTheme.semanticColors.cardSurface - SwipeToDeleteRow(onDelete = onDelete) { + SwipeToDeleteRow(onDelete = onDelete, onClick = onOpenDetail) { Row( modifier = Modifier .fillMaxWidth() @@ -173,3 +177,44 @@ private fun MemoryRow( } } } + +/** + * 记忆详情:名称、来源、描述与完整正文。 + * + * 注入到系统提示词的只有「名称 + 描述」,正文平时看不到,详情页才展开。 + */ +@Composable +internal fun MemoryDetailSheet( + memory: Memory, + onDismiss: () -> Unit +) { + AdaptiveModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = memory.name) + SettingsGroup { + SettingsRow( + icon = null, + title = memory.description.ifBlank { stringResource(R.string.mcp_no_description) }, + subtitle = stringResource( + if (memory.kind == MemoryKind.PROFILE) R.string.memory_source_auto + else R.string.memory_source_manual + ) + ) + } + SettingsGroupHeader(text = stringResource(R.string.memory_detail_content)) + SettingsGroup { + Text( + text = memory.content.ifBlank { stringResource(R.string.memory_detail_empty) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(Spacing.lg) + ) + } + } + } +} diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index a6aa613d3..55ded1e89 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -832,12 +832,17 @@ fun SettingsScreen( // 每次进入本分区重扫:引擎可能在后台刚沉淀了新条目, // 而 VM 在设置页返回栈里常驻,只在 init 扫一次会永远是旧列表。 LaunchedEffect(Unit) { memoryViewModel.refresh() } + var detailMemory by remember { mutableStateOf(null) } MemorySection( memories = memories, autoDistillEnabled = autoDistill, onToggleAutoDistill = memoryViewModel::setAutoDistillEnabled, + onOpenDetail = { detailMemory = it }, onDelete = memoryViewModel::delete ) + detailMemory?.let { memory -> + MemoryDetailSheet(memory = memory, onDismiss = { detailMemory = null }) + } } SettingsSection.SkillDetail -> selectedSkill?.let { entry -> SkillDetailSection( diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 43bcf40a5..cb8234929 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -971,6 +971,10 @@ Whatever you ask the assistant to remember shows up here Auto-save long-term memory After each turn, distill stable conclusions about you into memory (uses the model; off by default) + Auto-saved + Saved during chat + Content + (no content) No skills installed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86355412e..6433b356e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -971,6 +971,10 @@ 对话中让 AI 记住的内容会显示在这里 长期记忆自动沉淀 每轮对话结束后归纳关于你的结论并写入记忆(会调用模型,默认关闭) + 自动沉淀 + 对话中记录 + 内容 + (暂无正文) 还没有安装技能 From a8f4fd86bddee06d9302be6851f8d9cdf4122484 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:14:15 +0000 Subject: [PATCH 08/14] =?UTF-8?q?fix(memory):=20=E8=A1=A5=20AdaptiveModalB?= =?UTF-8?q?ottomSheet=20=E7=9A=84=20ExperimentalMaterial3Api=20opt-in?= =?UTF-8?q?=EF=BC=88CI=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemorySection.kt:191 用 AdaptiveModalBottomSheet(Material3 实验 API)未加 @OptIn(ExperimentalMaterial3Api::class),编译报 experimental API 错误。 --- .../feature/settings/presentation/component/MemorySection.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt index 1ec47e918..581058b20 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -183,6 +184,7 @@ private fun MemoryRow( * * 注入到系统提示词的只有「名称 + 描述」,正文平时看不到,详情页才展开。 */ +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MemoryDetailSheet( memory: Memory, From 6e80d5518bf52678c1b522f25c9d4708967f903f Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:17:45 +0000 Subject: [PATCH 09/14] =?UTF-8?q?perf(memory):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E6=94=B9=E5=8F=8C=E8=B7=AF=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=BB=E6=A8=A1=E5=9E=8B=E9=A1=BA=E6=89=8B=E8=AE=B0=20+=20?= =?UTF-8?q?=E6=94=92=E5=A4=9F=E8=BD=AE=E6=AC=A1=E5=86=8D=E5=BD=92=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:识别率不够高,且要省 token。原方案每轮结束后额外调一次模型、只喂 20 条消息, token 花在重复喂上下文上,识别率还被窗口限制住。 A 路(主路,≈零额外 token): - MemoryModule 在开关打开时往系统提示词注入一段「主动记忆」规则:发现关于用户的稳定结论 (偏好/习惯/纠正/环境限制/项目约定)就用 memory(action=save, scope=global) 存下来 - 判据是语义的(「未来会话里知道这条会不会让我做法不同」),不是关键词清单 - 关键修正:清单为空时也要注入规则——否则新用户没有任何记忆时规则永远不生效 - 缓存 key 带上开关状态(切换后注入内容要跟着变) - MemorySettingsRepository 加同步快照(提示词片段是同步拼的,读不了 DataStore) B 路(兜底,低频): - 从「每轮归约」改为「每会话攒够 5 轮归约一次」,计数在内存里,会话删除时清理 - 仍用独立模型调用与语义判据提示词,负责补上主模型漏掉的隐含偏好 单测更新:写入用例改为跑满 5 轮;新增「不足 5 轮不归约」用例;开关关闭用例改跑 5 轮验证不写。 --- .../domain/engine/modules/MemoryModule.kt | 52 ++++++++++++++++--- .../repository/MemorySettingsRepository.kt | 22 ++++++++ .../domain/engine/modules/MemoryModuleTest.kt | 21 ++++++-- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 857d59b2a..f816e2d5b 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -37,22 +37,36 @@ class MemoryModule @Inject constructor( // 注入顺序:记忆清单跟着「项目规则 / 技能」这类上下文走,用默认序即可。 override val order = 50 - private val cachedByKey = ConcurrentHashMap, String>() + private val cachedByKey = ConcurrentHashMap, String>() + + /** 每个会话已积累的轮数,够 [DISTILL_EVERY_TURNS] 才归约一次。 */ + private val turnsSinceDistill = ConcurrentHashMap() override fun promptFragment(ctx: EngineContext): String? { - val key = ctx.sessionId to ctx.projectRoot + val autoSave = memorySettings.autoDistillEnabledSync() + // 开关也进缓存 key:切换开关后注入内容要跟着变 + val key = Triple(ctx.sessionId, ctx.projectRoot, autoSave) val cached = cachedByKey[key] if (cached != null) return cached.ifEmpty { null } + // 清单为空时也要可能返回规则本身(新用户没有任何记忆时,主动记忆规则必须照样注入) + val content = listOfNotNull( + AUTO_SAVE_RULE.takeIf { autoSave }, + buildMemoryList(ctx) + ).joinToString("\n\n") + + cachedByKey[key] = content + trimIfNeeded() + return content.ifEmpty { null } + } + + private fun buildMemoryList(ctx: EngineContext): String? { val memories = try { memoryRepository.listMemories(ctx.projectRoot) } catch (e: Exception) { return null } - if (memories.isEmpty()) { - cachedByKey[key] = "" - return null - } + if (memories.isEmpty()) return null val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } val projectMemories = memories.filter { it.scope == MemoryScope.PROJECT } @@ -69,8 +83,6 @@ class MemoryModule @Inject constructor( } }.trimEnd() - cachedByKey[key] = content - trimIfNeeded() return content } @@ -85,6 +97,15 @@ class MemoryModule @Inject constructor( if (ctx.history.isEmpty()) return val complete = ctx.oneShot ?: return if (!memorySettings.autoDistillEnabled()) return + val sessionKey = ctx.sessionId ?: return + + // 攒够若干轮才归约一次:每轮都调模型太贵,而主路的主动记忆已经覆盖了当轮偏好。 + val turns = (turnsSinceDistill[sessionKey] ?: 0) + 1 + if (turns < DISTILL_EVERY_TURNS) { + turnsSinceDistill[sessionKey] = turns + return + } + turnsSinceDistill[sessionKey] = 0 val existing = runCatching { memoryRepository.listMemories(ctx.projectRoot) } .getOrDefault(emptyList()) @@ -112,6 +133,7 @@ class MemoryModule @Inject constructor( override suspend fun onSessionDeleted(ctx: EngineContext) { cachedByKey.keys.removeAll { it.first == ctx.sessionId } + ctx.sessionId?.let { turnsSinceDistill.remove(it) } } /** 交给模型的输入:已有长期记忆(防重复)+ 本轮对话(截断,控制 token)。 */ @@ -166,6 +188,20 @@ class MemoryModule @Inject constructor( const val TAG = "MemoryModule" const val SOURCE_CACHE_LIMIT = 64 + /** B 路归约间隔:每这么多轮才跑一次独立归纳。 */ + const val DISTILL_EVERY_TURNS = 5 + + /** + * 主动记忆规则(A 路):开关打开时注入,让主模型在对话中自己把稳定结论存下来。 + * 判据是语义的(「未来会话里知道这条会不会让我做法不同」),不是关键词清单: + * 用户几乎不会明说「记住这个」,偏好大多是隐含的。 + */ + val AUTO_SAVE_RULE = """ + 长期记忆:当对话里出现关于用户的稳定结论——偏好、工作习惯、对你的纠正、环境限制、项目约定—— + 用 memory(action=save, scope=global) 记下来,名称用简短稳定的英文 slug,描述一句话,正文一到三行。 + 判断标准是「在未来会话里知道这条会不会让我做法不同」;不要记一次性任务细节、工具输出或代码片段。 + """.trimIndent() + /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */ const val PROMPT_FILE = "agent/memory-distiller.md" diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt index 57f64976c..484bdd86c 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt @@ -5,9 +5,13 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -28,6 +32,22 @@ class MemorySettingsRepository @Inject constructor( val autoDistillEnabledFlow: Flow = context.memoryDataStore.data .map { it[AUTO_DISTILL_ENABLED_KEY] ?: false } + /** + * 同步快照:系统提示词片段是同步拼接的(读不了 DataStore), + * 而提示词里要不要加「主动记记忆」的规则取决于本开关。 + * 由下面的收集器持续刷新,进程启动后很快就有值。 + */ + @Volatile + private var autoDistillEnabledSnapshot: Boolean = false + + init { + scope.launch { + autoDistillEnabledFlow.collect { autoDistillEnabledSnapshot = it } + } + } + + fun autoDistillEnabledSync(): Boolean = autoDistillEnabledSnapshot + suspend fun autoDistillEnabled(): Boolean = autoDistillEnabledFlow.first() suspend fun setAutoDistillEnabled(enabled: Boolean) { @@ -37,4 +57,6 @@ class MemorySettingsRepository @Inject constructor( private companion object { val AUTO_DISTILL_ENABLED_KEY = booleanPreferencesKey("auto_distill_enabled") } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt index 856d8431d..ab24ac245 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt @@ -38,7 +38,8 @@ class MemoryModuleTest { val (module, repository) = module(settingsEnabled = true) val json = """[{"name":"prefers-brief","description":"Prefers brief answers","content":"No preambles."}]""" - module.onTurnCompleted(ctx { _, _ -> json }) + // 归约是攒够 DISTILL_EVERY_TURNS 轮才跑一次 + repeat(5) { module.onTurnCompleted(ctx { _, _ -> json }) } coVerify(exactly = 1) { repository.saveMemory( @@ -52,11 +53,21 @@ class MemoryModuleTest { } } + @Test + fun onTurnCompleted_waitsUntilEnoughTurnsBeforeDistilling() = runTest { + val (module, repository) = module(settingsEnabled = true) + + repeat(4) { module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) } + + coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } + } + @Test fun onTurnCompleted_doesNothingWhenSwitchOff() = runTest { val (module, repository) = module(settingsEnabled = false) - module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) + // 攒够轮数也不写(开关关着直接在计数前返回) + repeat(5) { module.onTurnCompleted(ctx { _, _ -> """[{"name":"a","content":"b"}]""" }) } coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } } @@ -75,9 +86,9 @@ class MemoryModuleTest { fun onTurnCompleted_ignoresGarbageAndEmptyResults() = runTest { val (module, repository) = module(settingsEnabled = true) - module.onTurnCompleted(ctx { _, _ -> "抱歉,这轮没有值得记住的内容。" }) - module.onTurnCompleted(ctx { _, _ -> "[]" }) - module.onTurnCompleted(ctx { _, _ -> null }) + repeat(5) { module.onTurnCompleted(ctx { _, _ -> "抱歉,这轮没有值得记住的内容。" }) } + repeat(5) { module.onTurnCompleted(ctx { _, _ -> "[]" }) } + repeat(5) { module.onTurnCompleted(ctx { _, _ -> null }) } coVerify(exactly = 0) { repository.saveMemory(any(), any(), any(), any(), any(), any()) } } From 612ea4bd78f331c01414c9fee43a1f016caffd64 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:30:33 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix(memory):=20=E4=BF=AE=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=A4=B1=E8=B4=A5=E7=9A=84=E5=9B=9B=E5=A4=84=E7=96=8F?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemoryModule:缓存 key 改成 Triple(会话,工作区,开关) 后,清理处仍在用 Pair 拼 key, 类型推断失败 → 改为按会话+工作区过滤后逐条移除 - MemorySettingsRepository:init 块用了声明在其后的 scope(Kotlin 按声明顺序初始化), 报 Variable 'scope' must be initialized → 把 scope 声明挪到 init 之前 --- .../feature/agent/domain/engine/modules/MemoryModule.kt | 5 ++++- .../settings/data/repository/MemorySettingsRepository.kt | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index f816e2d5b..79d4bd71e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -128,7 +128,10 @@ class MemoryModule @Inject constructor( } FileLogger.i(TAG, "沉淀长期记忆 ${entries.size} 条: ${entries.joinToString { it.name }}") // 本轮新增/更新了记忆 → 丢掉本会话的注入缓存,下一轮注入就带上新内容 - cachedByKey.remove(ctx.sessionId to ctx.projectRoot) + // (缓存 key 是 Triple(会话, 工作区, 开关),这里按前两项清,与开关无关) + cachedByKey.keys + .filter { it.first == ctx.sessionId && it.second == ctx.projectRoot } + .forEach { cachedByKey.remove(it) } } override suspend fun onSessionDeleted(ctx: EngineContext) { diff --git a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt index 484bdd86c..ad92fb62c 100644 --- a/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt +++ b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt @@ -40,6 +40,9 @@ class MemorySettingsRepository @Inject constructor( @Volatile private var autoDistillEnabledSnapshot: Boolean = false + // 声明在 init 之前:Kotlin 按声明顺序初始化,写在后面 init 里会报「必须初始化」 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + init { scope.launch { autoDistillEnabledFlow.collect { autoDistillEnabledSnapshot = it } @@ -57,6 +60,4 @@ class MemorySettingsRepository @Inject constructor( private companion object { val AUTO_DISTILL_ENABLED_KEY = booleanPreferencesKey("auto_distill_enabled") } - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } From fcf4103102ee2e5acd65a9491b3c2d52ae891438 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:13:54 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(memory):=20A=20=E8=B7=AF=E4=B8=8D?= =?UTF-8?q?=E7=94=9F=E6=95=88=E2=80=94=E2=80=94=E6=8A=8A=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E8=A7=84=E5=88=99=E5=86=99=E5=BC=BA=EF=BC=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=8F=8F=E8=BF=B0=E6=94=B9=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E8=B6=8A=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测:AI 从不主动记,条目全是 B 路(自动沉淀)产生的。 排查结论:工具已注册、保存是 AUTO_APPROVE(无需授权),所以不是权限问题, 而是模型没有动机去调——原来的两处描述都太弱: - 系统提示词里那段规则太软(只说「用 memory 记下来」),埋在片段里容易被忽略; - 工具描述是「管理 AI 的长期记忆……发现时主动记录」这种通用口吻,缺判据。 改动: - 系统提示词规则改成命令式、带判据、带收尾动作:每轮收尾前先判断是否出现关于用户的 稳定结论(偏好/习惯/纠正/环境限制/项目约定/反复术语/自身事实),判据是 「未来会话里知道这条会不会做法不同」,命中就调 memory(action=save, scope=global), 不等用户说「记住」;保存成功后在回复末尾用一行说明记了什么(顺带解决「无感」) - 工具描述去掉无条件的「主动记录」(否则开关关掉也会记,开关形同失效), 改为说明用途与「保存无需确认」,并指向系统提示词里的规则 --- .../agent/domain/engine/modules/MemoryModule.kt | 11 ++++++++--- .../feature/agent/domain/tool/memory/MemoryTool.kt | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 79d4bd71e..98a6c6891 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -200,9 +200,14 @@ class MemoryModule @Inject constructor( * 用户几乎不会明说「记住这个」,偏好大多是隐含的。 */ val AUTO_SAVE_RULE = """ - 长期记忆:当对话里出现关于用户的稳定结论——偏好、工作习惯、对你的纠正、环境限制、项目约定—— - 用 memory(action=save, scope=global) 记下来,名称用简短稳定的英文 slug,描述一句话,正文一到三行。 - 判断标准是「在未来会话里知道这条会不会让我做法不同」;不要记一次性任务细节、工具输出或代码片段。 + 长期记忆(开关已打开,你需要主动维护): + 每轮回复收尾前,先判断这一轮是否出现了关于用户的稳定结论—— + 偏好与表达习惯、工作方式、对你做法的纠正、环境或工具限制(设备/网络/权限/跑不起来的东西)、 + 项目约定与架构决策、反复出现的术语与路径、关于用户自身的稳定事实。 + 判据:如果未来某次会话一开始就知道这条,你会不会做得不一样?会 → 调用 + memory(action=save, scope=global, name=<短英文 slug>, description=<一句话>, content=<1-3 行>) 存下来。 + 不要等用户说「记住」,也不要等他要求;发现即记。保存成功后在回复末尾用一行说明记了什么。 + 不要记:一次性任务细节、工具输出、代码片段、明天就过期的状态。 """.trimIndent() /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */ diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt index 6aa98fee8..2e1851ebd 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/memory/MemoryTool.kt @@ -39,7 +39,8 @@ class MemoryTool @Inject constructor( } } override val description = - "管理 AI 的长期记忆(读取/保存/局部编辑/删除/列表)。发现新的用户偏好、项目约定或架构决策时主动记录。" + "管理长期记忆(读取/保存/局部编辑/删除/列表)。记忆跨会话生效,用于保留用户的稳定偏好、" + + "项目约定、架构决策等;保存与编辑无需用户确认。什么时候该主动保存,见系统提示词里的长期记忆规则。" /** edits 数组单个元素的结构,供 function-calling 的 items schema,语义与 editFile 一致。 */ private val editItemSchema: Map = mapOf( From 608ca61711632f6297e80df29a1fa043f7a5bcb4 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:35:44 +0000 Subject: [PATCH 12/14] =?UTF-8?q?fix(memory):=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E5=9B=9B=E5=A4=84=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 主动记忆写进去后不生效(真 bug) MemoryModule 按会话缓存注入内容以保持 system prompt 稳定,但模型调 memory 工具写入时 缓存不知道内容变了 → 新记忆要等到换会话才注入,A 路等于白写。 MemoryRepository 新增 changes 信号(保存/编辑/删除成功后发出),模块订阅后清缓存。 ② 子代理也拿到了主动记忆规则 规则原本只按开关判断,子代理会话同样注入 → 子代理会去写用户画像。 改为子代理不注入该规则(写画像是主代理的事)。 ③ 注入清单没有上限 记忆条数多了会把系统提示词撑爆。改为最多注入 40 条,超出部分提示 「另有 N 条未列出,需要时用 memory(action=list) 查看」。 ④ 归约可能重叠 归约由引擎并发分发,上一轮没跑完时下一轮又触发会重复写入。改为每会话一把 Mutex,tryLock 拿不到就跳过本次。 单测同步:mock 出 changes 为空流(模块 init 会订阅)。 --- .../domain/engine/modules/MemoryModule.kt | 46 +++++++++++++++++-- .../agent/domain/memory/MemoryRepository.kt | 29 ++++++++++-- .../domain/engine/modules/MemoryModuleTest.kt | 2 + 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 98a6c6891..91b67ea9e 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -12,6 +12,11 @@ import com.aicode.feature.settings.data.repository.MemorySettingsRepository import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -42,6 +47,20 @@ class MemoryModule @Inject constructor( /** 每个会话已积累的轮数,够 [DISTILL_EVERY_TURNS] 才归约一次。 */ private val turnsSinceDistill = ConcurrentHashMap() + /** 每会话一把锁:归约是并发分发的,上一次没跑完就不开新的,避免重复写入。 */ + private val distillLocks = ConcurrentHashMap() + + // 声明在 init 之前:Kotlin 按声明顺序初始化 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + // 记忆被外部改动(如模型主动调 memory 工具写入)时丢掉注入缓存, + // 否则新记忆要等到换会话才生效——主动记忆就白写了。 + scope.launch { + memoryRepository.changes.collect { cachedByKey.clear() } + } + } + override fun promptFragment(ctx: EngineContext): String? { val autoSave = memorySettings.autoDistillEnabledSync() // 开关也进缓存 key:切换开关后注入内容要跟着变 @@ -51,7 +70,8 @@ class MemoryModule @Inject constructor( // 清单为空时也要可能返回规则本身(新用户没有任何记忆时,主动记忆规则必须照样注入) val content = listOfNotNull( - AUTO_SAVE_RULE.takeIf { autoSave }, + // 子代理不拿主动记忆规则:写用户画像是主代理的事,子代理只管干活 + AUTO_SAVE_RULE.takeIf { autoSave && !ctx.isSubAgent }, buildMemoryList(ctx) ).joinToString("\n\n") @@ -68,8 +88,10 @@ class MemoryModule @Inject constructor( } if (memories.isEmpty()) return null - val globalMemories = memories.filter { it.scope == MemoryScope.GLOBAL } - val projectMemories = memories.filter { it.scope == MemoryScope.PROJECT } + // 注入有上限:记忆多了不能把系统提示词撑爆(未列出的靠 memory(action=list) 取) + val listed = memories.take(MAX_INJECTED_MEMORIES) + val globalMemories = listed.filter { it.scope == MemoryScope.GLOBAL } + val projectMemories = listed.filter { it.scope == MemoryScope.PROJECT } val content = buildString { if (globalMemories.isNotEmpty()) { @@ -81,6 +103,9 @@ class MemoryModule @Inject constructor( append("项目记忆 (当前项目专属,需要详情时用 memory(action=read, name=xxx, scope=project)):\n") projectMemories.forEach { append("- ${it.name}: ${it.description.ifBlank { "无" }}\n") } } + if (memories.size > listed.size) { + append("\n(另有 ${memories.size - listed.size} 条未列出,需要时用 memory(action=list) 查看)") + } }.trimEnd() return content @@ -107,6 +132,18 @@ class MemoryModule @Inject constructor( } turnsSinceDistill[sessionKey] = 0 + // 归约由引擎并发分发,上一轮可能还没跑完;同一会话不重叠,避免重复写入 + val lock = distillLocks.getOrPut(sessionKey) { Mutex() } + if (!lock.tryLock()) return + try { + distill(ctx, complete) + } finally { + lock.unlock() + } + } + + private suspend fun distill(ctx: EngineContext, complete: suspend (String, String) -> String?) { + val existing = runCatching { memoryRepository.listMemories(ctx.projectRoot) } .getOrDefault(emptyList()) .filter { it.kind == MemoryKind.PROFILE } @@ -217,5 +254,8 @@ class MemoryModule @Inject constructor( const val MAX_MESSAGE_CHARS = 1200 const val MAX_TOOL_CHARS = 400 const val MAX_ENTRIES_PER_TURN = 5 + + /** 注入系统提示词的记忆条数上限,超出的靠 memory(action=list) 取。 */ + const val MAX_INJECTED_MEMORIES = 40 } } diff --git a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt index 080ee54a3..1adc3b8e0 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/memory/MemoryRepository.kt @@ -3,6 +3,9 @@ package com.aicode.feature.agent.domain.memory import com.aicode.feature.agent.domain.container.ContainerInstaller import com.aicode.feature.settings.data.repository.ExecutionModeHolder import com.aicode.feature.workspace.domain.ProjectAicodeRoot +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import javax.inject.Inject import javax.inject.Singleton @@ -17,6 +20,20 @@ class MemoryRepository @Inject constructor( private fun projectSource(projectRoot: String) = ProjectMemorySource(projectRoot, executionModeHolder, containerInstaller, projectAicodeRoot) + private val _changes = MutableSharedFlow(extraBufferCapacity = 8) + + /** + * 记忆被写入 / 编辑 / 删除时发信号。 + * + * 注入方(MemoryModule)按会话缓存注入内容以保持 system prompt 稳定, + * 若不知道内容变了,新记忆要等到换会话才生效——主动记忆(模型调工具写)就白写了。 + */ + val changes: SharedFlow = _changes.asSharedFlow() + + private fun notifyChanged() { + _changes.tryEmit(Unit) + } + /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。kind 非空时只返回该类型。 */ fun listMemories(projectRoot: String?, kind: MemoryKind? = null): List { val allMemories = mutableListOf() @@ -55,32 +72,38 @@ class MemoryRepository @Inject constructor( projectRoot: String?, kind: MemoryKind = MemoryKind.NOTE ): Boolean { - return when (scope) { + val saved = when (scope) { MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content, kind) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) false else projectSource(projectRoot).saveMemory(name, description, content, kind) } } + if (saved) notifyChanged() + return saved } fun editMemory(name: String, edits: List, scope: MemoryScope, projectRoot: String?): MemoryEditResult { - return when (scope) { + val result = when (scope) { MemoryScope.GLOBAL -> globalMemorySource.editMemory(name, edits) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) MemoryEditResult.Error("NO_WORKSPACE", "当前未选择工作区,无法编辑项目级记忆") else projectSource(projectRoot).editMemory(name, edits) } } + if (result is MemoryEditResult.Success) notifyChanged() + return result } fun deleteMemory(name: String, scope: MemoryScope, projectRoot: String?): Boolean { - return when (scope) { + val deleted = when (scope) { MemoryScope.GLOBAL -> globalMemorySource.deleteMemory(name) MemoryScope.PROJECT -> { if (projectRoot.isNullOrBlank()) false else projectSource(projectRoot).deleteMemory(name) } } + if (deleted) notifyChanged() + return deleted } } diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt index ab24ac245..1b0d2ee70 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt @@ -10,6 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,6 +24,7 @@ class MemoryModuleTest { val settings = mockk() coEvery { settings.autoDistillEnabled() } returns settingsEnabled every { repository.listMemories(any()) } returns emptyList() + every { repository.changes } returns emptyFlow() return MemoryModule(repository, settings) to repository } From c5dd26f01cb84f784d467072ecc6c32f307a24d8 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:06:49 +0000 Subject: [PATCH 13/14] =?UTF-8?q?fix(test):=20MemoryModuleTest=20=E7=94=A8?= =?UTF-8?q?=20MutableSharedFlow=20=E6=9B=BF=20emptyFlow=EF=BC=88=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20CI=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 PR 的 CI 报 MemoryModuleTest.kt:27:actual type is 'Flow', but 'SharedFlow' was expected。根因是 repository.changes 类型为 SharedFlow, 而 emptyFlow() 返回 Flow,两者不兼容(不只是类型参数没给)。 本地 CI 没抓到的原因:那几轮反复 force-push 把 CI 里跑单测的步骤顶掉了。 教训:force-push 后必须等 CI 跑完再动。 --- .../feature/agent/domain/engine/modules/MemoryModuleTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt index 1b0d2ee70..0fa5e10b4 100644 --- a/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt +++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt @@ -10,7 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,7 +24,8 @@ class MemoryModuleTest { val settings = mockk() coEvery { settings.autoDistillEnabled() } returns settingsEnabled every { repository.listMemories(any()) } returns emptyList() - every { repository.changes } returns emptyFlow() + // changes 的类型是 SharedFlow,不能用 emptyFlow(那是 Flow) + every { repository.changes } returns MutableSharedFlow() return MemoryModule(repository, settings) to repository } From 7ae6e6d32965f8e4f8dd439b2f0c2b236371e367 Mon Sep 17 00:00:00 2001 From: Rely-xcy <179817675+Rely-xcy@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:16:36 +0000 Subject: [PATCH 14/14] =?UTF-8?q?fix(memory):=20=E6=B2=89=E6=B7=80?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E6=94=B9=E6=A0=87=E7=AD=BE=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=86=99=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 main 上同一处修正(a789338e):让模型输出 JSON 太不可靠——漏引号、多逗号、 把换行写进字符串、前后多一段说明,任何一处都会让整个数组解析失败,这一批记忆就白抽了。 - 改成 name/description/content 三行标签块,逐行容错解析: 空行、--- 分隔、代码块围栏、中英文冒号、行首项目符号都能吃掉,多行 content 归属上一条 - 坏一条只丢一条,不影响整批;保留 JSON 兜底(标签格式没解出条目时再试一次) - assets/prompts/agent/memory-distiller.md 的输出格式说明与示例同步改为标签块 - 现有单测喂的是 JSON,正好覆盖兜底路径 --- .../assets/prompts/agent/memory-distiller.md | 42 +++++++++++---- .../domain/engine/modules/MemoryModule.kt | 54 ++++++++++++++++++- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/app/src/main/assets/prompts/agent/memory-distiller.md b/app/src/main/assets/prompts/agent/memory-distiller.md index 325b44bb0..46bbd1e24 100644 --- a/app/src/main/assets/prompts/agent/memory-distiller.md +++ b/app/src/main/assets/prompts/agent/memory-distiller.md @@ -1,5 +1,7 @@ - -You maintain long-term memory about the user. Output ONLY a JSON array. + +You maintain long-term memory about the user. Output ONLY memory entries in the tag format below — no JSON, no code fences, no extra prose. Read the transcript and the existing memory list, then decide which facts about the user are worth carrying into future sessions. @@ -25,21 +27,41 @@ most of what matters has to be INFERRED from what they do. Signals include: - they reveal something about themselves: role, expertise, language, time zone, what they care about + +One block per memory, separated by a blank line. Exactly these three labels, each at the start of a line: + +name: short-stable-slug +description: one line summary +content: one to three short lines of detail + +Rules: +- The value may follow the label on the same line, or start on the following lines. +- No JSON, no braces, no quotes around values, no bullet markers, no code fences. +- Write the values in the same language the user writes in. +- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates of the same fact. +- "description": one line, at most 60 characters — it is injected into the system prompt as the summary. +- "content": one to three short lines, no headings. +- Output nothing at all when the transcript contains nothing durable. + + - Prefer recording over skipping: a missing memory is worse than an extra one, and the user can delete entries in the app. - Record the user's durable traits, not the task. Skip one-off task details, transient state, tool output, code snippets, or anything stale tomorrow. - Write each entry as a fact about the user, not as a summary of what happened in the conversation. - Never duplicate an existing memory with the same meaning. If the transcript corrects or extends one, reuse the SAME "name" so the new entry replaces the old one. - A turn may yield zero, one, or several entries — do not force one, but do not miss an implicit preference. -- Write the values in the same language the user writes in. -- "name": short stable slug, only lowercase letters, digits, hyphen and underscore (it becomes a filename). Keep it stable across updates of the same fact. -- "description": one line, at most 60 characters — it is injected into the system prompt as the summary. -- "content": one to three short lines of detail, no headings, no bullet markers. -- Output [] only when the transcript really contains nothing durable. Output raw JSON only: no prose, no code fences, no trailing text. -User repeats a request they already made twice → [{"name":"needs-explicit-confirmation","description":"Repeats instructions; confirm before acting","content":"Has had to repeat the same instruction more than once — restate the plan and confirm before starting."}] -User pastes a build error from their own machine → [{"name":"local-build-environment","description":"Builds locally and hits environment errors","content":"Runs builds on their own machine; container-side fixes must account for their local toolchain."}] -User: "帮我看看这个函数" (nothing else) → [] +User repeats a request they already made twice: + +name: needs-explicit-confirmation +description: Repeats instructions; confirm before acting +content: Has had to repeat the same instruction more than once — restate the plan and confirm before starting. + +User pastes a build error from their own machine: + +name: build-env-no-local-gradle +description: Cannot build locally; relies on CI +content: Local machine cannot run gradle; verification has to go through CI runs. diff --git a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt index 91b67ea9e..5a4e00b40 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt @@ -197,8 +197,53 @@ class MemoryModule @Inject constructor( "tool(${message.toolName}): ${(message.modelResult ?: message.result).take(MAX_TOOL_CHARS)}" } - /** 解析模型输出:容错地取出 JSON 数组段,容忍 ``` 包裹与前后废话。 */ + /** + * 解析模型输出:按 `name:` / `description:` / `content:` 标签逐行读,不要求 JSON。 + * + * 为什么不要求 JSON:模型输出 JSON 的失败率明显更高——漏引号、多逗号、把换行写进字符串、 + * 前后多一段说明文字,任何一处都会让整个数组解析失败,这一批记忆就白抽了。 + * 标签格式容错得多:空行、`---` 分隔、代码块围栏、中英文冒号、行首项目符号都能吃掉, + * 单条写坏了也只丢那一条。 + */ private fun parseEntries(raw: String): List { + val entries = mutableListOf() + var name = "" + var description = "" + val content = StringBuilder() + + fun flush() { + if (name.isNotBlank() && content.isNotBlank()) { + entries.add(DistilledEntry(name.trim(), description.trim(), content.toString().trim())) + } + name = "" + description = "" + content.clear() + } + + raw.lines().forEach { line -> + val cleaned = line.trim().removePrefix("-").removePrefix("*").trim().trim('`').trim() + when { + cleaned.isEmpty() || cleaned == "---" -> Unit + + NAME_LABEL.matches(cleaned) -> { + flush() + name = NAME_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty() + } + + DESCRIPTION_LABEL.matches(cleaned) -> + description = DESCRIPTION_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty() + + CONTENT_LABEL.matches(cleaned) -> + content.appendLine(CONTENT_LABEL.matchEntire(cleaned)?.groupValues?.get(1).orEmpty()) + + // 多行 content:从属于上一条 name + name.isNotBlank() -> content.appendLine(cleaned) + } + } + flush() + if (entries.isNotEmpty()) return entries.take(MAX_ENTRIES_PER_TURN) + + // 模型万一还是吐了 JSON:按旧方式再试一次,不白丢这一批 val start = raw.indexOf('[') val end = raw.lastIndexOf(']') if (start < 0 || end <= start) return emptyList() @@ -208,10 +253,15 @@ class MemoryModule @Inject constructor( .filter { it.name.isNotBlank() && it.content.isNotBlank() } .take(MAX_ENTRIES_PER_TURN) }.onFailure { - FileLogger.w(TAG, "沉淀结果解析失败,已跳过本轮", it) + FileLogger.w(TAG, "标签格式没解析出条目,JSON 兜底也失败,本轮跳过", it) }.getOrDefault(emptyList()) } + /** 标签格式的三个字段;中英文冒号都认,大小写不敏感。 */ + private val NAME_LABEL = Regex("^name\\s*[::]\\s*(.+)$", RegexOption.IGNORE_CASE) + private val DESCRIPTION_LABEL = Regex("^description\\s*[::]\\s*(.+)$", RegexOption.IGNORE_CASE) + private val CONTENT_LABEL = Regex("^content\\s*[::]\\s*(.*)$", RegexOption.IGNORE_CASE) + private fun trimIfNeeded() { if (cachedByKey.size > SOURCE_CACHE_LIMIT) cachedByKey.clear() }