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..46bbd1e24
--- /dev/null
+++ b/app/src/main/assets/prompts/agent/memory-distiller.md
@@ -0,0 +1,67 @@
+
+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.
+
+
+
+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
+
+
+
+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.
+
+
+
+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/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..5146ec9cd
--- /dev/null
+++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/EngineModule.kt
@@ -0,0 +1,65 @@
+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,
+ /**
+ * 一次性模型调用能力,由会话运行侧注入(不占主对话 provider、不写入会话消息)。
+ * 模块拿它做归纳类工作;null 表示当前不可用(如未配置模型)。
+ *
+ * 用回调而不是让模块注入 workflow:模块 → workflow → SystemPromptProvider →
+ * AgentEngine → 模块 会形成 DI 环,所以能力只能从调用方递进来。
+ */
+ val oneShot: (suspend (promptFile: String, userPrompt: String) -> String?)? = null
+)
+
+/**
+ * 引擎模块:一个功能以模块为单位接入引擎,由 [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..5a4e00b40
--- /dev/null
+++ b/app/src/main/java/com/aicode/feature/agent/domain/engine/modules/MemoryModule.kt
@@ -0,0 +1,311 @@
+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 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
+
+/**
+ * 记忆模块:把「AI 记住了什么」接进引擎。
+ *
+ * 两件事:
+ * 1. 注入——把记忆清单(名称 + 描述)拼进系统提示词,详情仍由模型调 `memory(action=read)` 自取;
+ * 2. 沉淀——每轮对话结束后(开关打开时)归纳出关于用户的稳定结论,写进长期记忆。
+ *
+ * 缓存策略沿用迁移前的实现:按 (sessionId, projectRoot) 会话级缓存,同一会话内只读一次盘,
+ * 保持 system prompt 稳定以命中 KV 缓存;空结果用 "" 占位以区分「未缓存」。
+ */
+@Singleton
+class MemoryModule @Inject constructor(
+ private val memoryRepository: MemoryRepository,
+ private val memorySettings: MemorySettingsRepository
+) : EngineModule {
+
+ override val id = MODULE_ID
+
+ // 注入顺序:记忆清单跟着「项目规则 / 技能」这类上下文走,用默认序即可。
+ override val order = 50
+
+ private val cachedByKey = ConcurrentHashMap, String>()
+
+ /** 每个会话已积累的轮数,够 [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:切换开关后注入内容要跟着变
+ 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 && !ctx.isSubAgent },
+ 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()) return null
+
+ // 注入有上限:记忆多了不能把系统提示词撑爆(未列出的靠 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()) {
+ 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") }
+ }
+ if (memories.size > listed.size) {
+ append("\n(另有 ${memories.size - listed.size} 条未列出,需要时用 memory(action=list) 查看)")
+ }
+ }.trimEnd()
+
+ 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 sessionKey = ctx.sessionId ?: return
+
+ // 攒够若干轮才归约一次:每轮都调模型太贵,而主路的主动记忆已经覆盖了当轮偏好。
+ val turns = (turnsSinceDistill[sessionKey] ?: 0) + 1
+ if (turns < DISTILL_EVERY_TURNS) {
+ turnsSinceDistill[sessionKey] = turns
+ return
+ }
+ 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 }
+
+ 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 }}")
+ // 本轮新增/更新了记忆 → 丢掉本会话的注入缓存,下一轮注入就带上新内容
+ // (缓存 key 是 Triple(会话, 工作区, 开关),这里按前两项清,与开关无关)
+ cachedByKey.keys
+ .filter { it.first == ctx.sessionId && it.second == ctx.projectRoot }
+ .forEach { cachedByKey.remove(it) }
+ }
+
+ override suspend fun onSessionDeleted(ctx: EngineContext) {
+ cachedByKey.keys.removeAll { it.first == ctx.sessionId }
+ ctx.sessionId?.let { turnsSinceDistill.remove(it) }
+ }
+
+ /** 交给模型的输入:已有长期记忆(防重复)+ 本轮对话(截断,控制 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)}"
+ }
+
+ /**
+ * 解析模型输出:按 `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()
+ 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, "标签格式没解析出条目,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()
+ }
+
+ @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
+
+ /** B 路归约间隔:每这么多轮才跑一次独立归纳。 */
+ const val DISTILL_EVERY_TURNS = 5
+
+ /**
+ * 主动记忆规则(A 路):开关打开时注入,让主模型在对话中自己把稳定结论存下来。
+ * 判据是语义的(「未来会话里知道这条会不会让我做法不同」),不是关键词清单:
+ * 用户几乎不会明说「记住这个」,偏好大多是隐含的。
+ */
+ val AUTO_SAVE_RULE = """
+ 长期记忆(开关已打开,你需要主动维护):
+ 每轮回复收尾前,先判断这一轮是否出现了关于用户的稳定结论——
+ 偏好与表达习惯、工作方式、对你做法的纠正、环境或工具限制(设备/网络/权限/跑不起来的东西)、
+ 项目约定与架构决策、反复出现的术语与路径、关于用户自身的稳定事实。
+ 判据:如果未来某次会话一开始就知道这条,你会不会做得不一样?会 → 调用
+ memory(action=save, scope=global, name=<短英文 slug>, description=<一句话>, content=<1-3 行>) 存下来。
+ 不要等用户说「记住」,也不要等他要求;发现即记。保存成功后在回复末尾用一行说明记了什么。
+ 不要记:一次性任务细节、工具输出、代码片段、明天就过期的状态。
+ """.trimIndent()
+
+ /** 沉淀提示词:与其它一次性调用一样放 assets/prompts 下。 */
+ const val PROMPT_FILE = "agent/memory-distiller.md"
+
+ const val TRANSCRIPT_MESSAGES = 20
+ 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/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..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,8 +20,22 @@ class MemoryRepository @Inject constructor(
private fun projectSource(projectRoot: String) =
ProjectMemorySource(projectRoot, executionModeHolder, containerInstaller, projectAicodeRoot)
- /** 扫描并聚合全局和项目级的 memory。同名 memory 项目级优先。 */
- fun listMemories(projectRoot: String?): List {
+ 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()
// 1. 加载全局记忆
@@ -30,9 +47,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,33 +64,46 @@ class MemoryRepository @Inject constructor(
return globalMemorySource.loadContent(name)
}
- fun saveMemory(name: String, description: String, content: String, scope: MemoryScope, projectRoot: String?): Boolean {
- return when (scope) {
- MemoryScope.GLOBAL -> globalMemorySource.saveMemory(name, description, content)
+ fun saveMemory(
+ name: String,
+ description: String,
+ content: String,
+ scope: MemoryScope,
+ projectRoot: String?,
+ kind: MemoryKind = MemoryKind.NOTE
+ ): Boolean {
+ 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)
+ 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/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/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..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
@@ -1,12 +1,15 @@
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
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
@@ -14,7 +17,10 @@ import javax.inject.Singleton
@Singleton
class SessionUseCase @Inject constructor(
private val chatSessionDao: ChatSessionDao,
- private val agentMessageDao: AgentMessageDao
+ private val agentMessageDao: AgentMessageDao,
+ // Lazy 断环:引擎 → 记忆模块 → MemoryRepository → ProjectAicodeRoot → WorkspaceRepository → 本类,
+ // 直接注入会形成 Dagger 依赖环;钩子只在删除时用一次,延迟取即可。
+ private val agentEngine: Lazy
) {
companion object {
private const val TAG = "SessionUseCase"
@@ -78,6 +84,10 @@ class SessionUseCase @Inject constructor(
}
agentMessageDao.deleteBySession(id)
chatSessionDao.delete(id)
+ // 交给引擎分发:模块自持的会话级状态(如记忆注入缓存)在这里释放
+ deleted.forEach { sessionId ->
+ agentEngine.get().onSessionDeleted(EngineContext(sessionId = sessionId))
+ }
return deleted
}
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(
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 7ed45525b..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
@@ -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,29 @@ class AIAgentViewModel @Inject constructor(
}
setStreamingText(sessionId, null)
+ // 一轮对话正常结束:交给引擎分发,模块自行判断开关与要不要干活。
+ // 放在成功路径(非 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 = turnHistory,
+ isSubAgent = sessionEntity?.parentId != null,
+ // 模块做归纳时的一次性模型调用:走独立 provider,不占主对话
+ oneShot = { promptFile, userPrompt ->
+ agentWorkflow.oneShotComplete(sessionId, promptFile, userPrompt, "memory-distill")
+ }
+ )
+ )
+ }
+
} catch (e: CancellationException) {
val cancelledState = _agentStates.value[sessionId]
val isOwnJob = sessionJobs[sessionId] == coroutineContext[Job]
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..ad92fb62c
--- /dev/null
+++ b/app/src/main/java/com/aicode/feature/settings/data/repository/MemorySettingsRepository.kt
@@ -0,0 +1,63 @@
+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.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
+
+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 }
+
+ /**
+ * 同步快照:系统提示词片段是同步拼接的(读不了 DataStore),
+ * 而提示词里要不要加「主动记记忆」的规则取决于本开关。
+ * 由下面的收集器持续刷新,进程启动后很快就有值。
+ */
+ @Volatile
+ private var autoDistillEnabledSnapshot: Boolean = false
+
+ // 声明在 init 之前:Kotlin 按声明顺序初始化,写在后面 init 里会报「必须初始化」
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ init {
+ scope.launch {
+ autoDistillEnabledFlow.collect { autoDistillEnabledSnapshot = it }
+ }
+ }
+
+ fun autoDistillEnabledSync(): Boolean = autoDistillEnabledSnapshot
+
+ 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
new file mode 100644
index 000000000..1e1eb4e18
--- /dev/null
+++ b/app/src/main/java/com/aicode/feature/settings/presentation/MemoryViewModel.kt
@@ -0,0 +1,67 @@
+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.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 合并去重,界面不再分栏),
+ * 支持删除,并提供「长期记忆自动沉淀」开关。
+ *
+ * 扫描磁盘记忆文件与删除都是 IO,统一放 IO 线程。
+ */
+@HiltViewModel
+class MemoryViewModel @Inject constructor(
+ private val memoryRepository: MemoryRepository,
+ 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()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ val projectRoot = workspaceRepository.currentPath()
+ _memories.value = withContext(Dispatchers.IO) {
+ memoryRepository.listMemories(projectRoot)
+ }
+ }
+ }
+
+ fun setAutoDistillEnabled(enabled: Boolean) {
+ viewModelScope.launch {
+ memorySettings.setAutoDistillEnabled(enabled)
+ }
+ }
+
+ 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..581058b20
--- /dev/null
+++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/MemorySection.kt
@@ -0,0 +1,222 @@
+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.ExperimentalMaterial3Api
+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.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
+
+/**
+ * 记忆页:长期记忆自动沉淀开关 + 当前生效的记忆列表(左滑删除)。
+ *
+ * 全局与项目记忆合并成一份清单、不分栏——用户看到的只是「AI 记住了什么」,
+ * 记忆存在哪一侧是实现细节。
+ */
+@Composable
+internal fun MemorySection(
+ memories: List,
+ autoDistillEnabled: Boolean,
+ onToggleAutoDistill: (Boolean) -> Unit,
+ onOpenDetail: (Memory) -> Unit,
+ onDelete: (Memory) -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = Spacing.lg)
+ .padding(bottom = Spacing.xl),
+ verticalArrangement = Arrangement.spacedBy(Spacing.sm)
+ ) {
+ SettingsGroup {
+ 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, onOpenDetail = { onOpenDetail(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(
+ memory: Memory,
+ onOpenDetail: () -> Unit,
+ onDelete: () -> Unit
+) {
+ val rowBackground = MaterialTheme.semanticColors.cardSurface
+
+ SwipeToDeleteRow(onDelete = onDelete, onClick = onOpenDetail) {
+ 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
+ )
+ }
+ }
+ }
+}
+
+/**
+ * 记忆详情:名称、来源、描述与完整正文。
+ *
+ * 注入到系统提示词的只有「名称 + 描述」,正文平时看不到,详情页才展开。
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@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 6f0180b34..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
@@ -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,27 @@ 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()
+ val autoDistill by memoryViewModel.autoDistillEnabled.collectAsStateWithLifecycle()
+ // 每次进入本分区重扫:引擎可能在后台刚沉淀了新条目,
+ // 而 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(
entry = entry,
@@ -1348,6 +1370,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..cb8234929 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,16 @@
%1$d tools
Details
+
+ 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)
+ Auto-saved
+ Saved during chat
+ Content
+ (no content)
+
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..6433b356e 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,16 @@
%1$d 个工具
详情
+
+ 还没有记忆
+ 对话中让 AI 记住的内容会显示在这里
+ 长期记忆自动沉淀
+ 每轮对话结束后归纳关于你的结论并写入记忆(会调用模型,默认关闭)
+ 自动沉淀
+ 对话中记录
+ 内容
+ (暂无正文)
+
还没有安装技能
点右上角「+」新建或导入
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/engine/modules/MemoryModuleTest.kt b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt
new file mode 100644
index 000000000..0fa5e10b4
--- /dev/null
+++ b/app/src/test/java/com/aicode/feature/agent/domain/engine/modules/MemoryModuleTest.kt
@@ -0,0 +1,98 @@
+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.flow.MutableSharedFlow
+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()
+ // changes 的类型是 SharedFlow,不能用 emptyFlow(那是 Flow)
+ every { repository.changes } returns MutableSharedFlow()
+ 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."}]"""
+
+ // 归约是攒够 DISTILL_EVERY_TURNS 轮才跑一次
+ repeat(5) { module.onTurnCompleted(ctx { _, _ -> json }) }
+
+ coVerify(exactly = 1) {
+ repository.saveMemory(
+ "prefers-brief",
+ "Prefers brief answers",
+ "No preambles.",
+ MemoryScope.GLOBAL,
+ "/ws",
+ MemoryKind.PROFILE
+ )
+ }
+ }
+
+ @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)
+
+ // 攒够轮数也不写(开关关着直接在计数前返回)
+ repeat(5) { 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)
+
+ 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()) }
+ }
+}
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)
+ }
}
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..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
@@ -3,9 +3,14 @@ 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 dagger.Lazy
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 +18,10 @@ import org.junit.Test
/** 删除工作区时按 workspacePath 级联清理会话与消息的行为。 */
class SessionUseCaseWorkspaceDeletionTest {
+ /** 空模块集的引擎:本测试只验证会话/消息删除,引擎侧钩子无副作用。 */
+ private fun emptyEngine(): Lazy =
+ Lazy { AgentEngine(emptySet(), CoroutineScope(SupervisorJob() + Dispatchers.IO)) }
+
private fun session(id: String, workspacePath: String, parentId: String? = null) = ChatSessionEntity(
id = id,
title = "t",
@@ -31,7 +40,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 +55,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)