From ed8ab84b4b839dd74c1b638775fdcffbd985cbe2 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 24 Aug 2026 10:51:00 +0800 Subject: [PATCH] feat(ai): integrate memory with global agent --- apps/cli/src/ai/agent-stream-runner.ts | 9 +- apps/cli/src/ai/cross-chat-tool-adapter.ts | 7 + apps/cli/src/http/index.ts | 9 + apps/desktop/main/ai/agent-stream-runner.ts | 7 + .../main/ai/cross-chat-tool-adapter.ts | 7 + apps/desktop/main/internal-api/server.ts | 19 ++ .../src/ai/__tests__/cross-chat-agent.test.ts | 40 ++- .../node-runtime/src/ai/cross-chat-agent.ts | 32 ++- packages/node-runtime/src/ai/index.ts | 2 +- packages/node-runtime/src/ai/memory.ts | 21 ++ packages/node-runtime/src/index.ts | 2 +- packages/node-runtime/src/preferences.test.ts | 1 + packages/node-runtime/src/preferences.ts | 1 + packages/shared-types/index.ts | 1 + packages/tools/src/agent-adapter.test.ts | 7 +- .../src/definitions/cross-chat-tools.test.ts | 26 +- .../src/definitions/memory-tools.test.ts | 233 ++++++++++++++++++ .../tools/src/definitions/memory-tools.ts | 203 +++++++++++++++ packages/tools/src/index.ts | 2 + packages/tools/src/registry.ts | 4 + packages/tools/src/types.ts | 29 +++ src/stores/prompt.ts | 2 + tests/global-ai-memory-loop.test.ts | 96 ++++++++ 23 files changed, 741 insertions(+), 19 deletions(-) create mode 100644 packages/tools/src/definitions/memory-tools.test.ts create mode 100644 packages/tools/src/definitions/memory-tools.ts create mode 100644 tests/global-ai-memory-loop.test.ts diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index 122cbdb5..6113fd6b 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -11,6 +11,7 @@ import type { SemanticIndexRuntime, CrossChatAnalysisService, SessionRuntimeAdapter, + AIMemoryService, } from '@openchatlab/node-runtime' import { CHART_CAPABILITY_CORE_TOOLS, @@ -84,6 +85,8 @@ export function createCliRunAgentStream( semanticIndexService?: SemanticIndexRuntime crossChatAnalysisService?: CrossChatAnalysisService sessionAdapter?: SessionRuntimeAdapter + memoryService?: AIMemoryService + getAllowProactiveMemory?: () => boolean } = {} ): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise { const aiDataDir = getAiDir(dbManager) @@ -144,7 +147,7 @@ export function createCliRunAgentStream( onEvent({ type: 'done', isFinished: true }) return } - if (!options.crossChatAnalysisService || !options.sessionAdapter) { + if (!options.crossChatAnalysisService || !options.sessionAdapter || !options.memoryService) { onEvent({ type: 'error', error: { name: 'RuntimeError', message: 'Cross-chat analysis is unavailable' } }) onEvent({ type: 'done', isFinished: true }) return @@ -155,6 +158,9 @@ export function createCliRunAgentStream( locale, preprocessConfig: params.preprocessConfig, maxToolResultTokens: resolveCrossChatToolResultTokenBudget(contextWindow), + memoryService: options.memoryService, + aiChatId, + allowProactiveMemory: options.getAllowProactiveMemory?.() ?? true, }) await runCrossChatAgent({ userMessage, @@ -166,6 +172,7 @@ export function createCliRunAgentStream( apiKey: llmConfig.apiKey, tools, aiChatManager, + memoryService: options.memoryService, onEvent, abortSignal, thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, diff --git a/apps/cli/src/ai/cross-chat-tool-adapter.ts b/apps/cli/src/ai/cross-chat-tool-adapter.ts index 0510241c..d02ebc71 100644 --- a/apps/cli/src/ai/cross-chat-tool-adapter.ts +++ b/apps/cli/src/ai/cross-chat-tool-adapter.ts @@ -1,5 +1,6 @@ import type { AgentTool, + AIMemoryService, CrossChatAnalysisService, PreprocessConfig, SessionRuntimeAdapter, @@ -18,10 +19,16 @@ export function createCliCrossChatTools(options: { locale?: string preprocessConfig?: Record maxToolResultTokens: number + memoryService: AIMemoryService + aiChatId: string + allowProactiveMemory: boolean }): AgentTool[] { const context: Omit = { locale: options.locale, analysisService: options.analysisService, + memoryService: options.memoryService, + aiChatId: options.aiChatId, + allowProactiveMemory: options.allowProactiveMemory, maxToolResultTokens: options.maxToolResultTokens, countTokens, preprocessMessagesBySession: (sessionId, messages) => diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index bc4da93a..42622f1f 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -27,6 +27,7 @@ import { createGlobalInsightService, createDatabaseManagerAdapter, PreferencesManager, + AIMemoryService, } from '@openchatlab/node-runtime' import type { SemanticIndexRuntime } from '@openchatlab/node-runtime' import { createServer } from './server' @@ -44,6 +45,7 @@ import { resolveCliLocalEmbeddingRuntimeConfig } from '../semantic-index/local-r let server: FastifyInstance | null = null let dbManager: DatabaseManager | null = null let aiChatManager: AIChatManager | null = null +let aiMemoryService: AIMemoryService | null = null export interface HttpServerOptions { port?: number @@ -165,6 +167,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ const aiDataDir = pathProvider.getAiDataDir() aiChatManager = new AIChatManager(aiDataDir, { nativeBinding }) + aiMemoryService = new AIMemoryService(aiDataDir, { nativeBinding }) const assistantManager = getAssistantManager(aiDataDir) const skillManagerCore = getSkillManagerCore(aiDataDir) @@ -228,6 +231,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ semanticIndexService, crossChatAnalysisService, sessionAdapter, + memoryService: aiMemoryService, + getAllowProactiveMemory: () => preferencesManager.load().aiGlobalSettings.allowProactiveMemory, }), }, }) @@ -270,6 +275,10 @@ export async function stopHttpServer(): Promise { aiChatManager.close() aiChatManager = null } + if (aiMemoryService) { + aiMemoryService.close() + aiMemoryService = null + } if (dbManager) { dbManager.closeAll() dbManager = null diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index 572cfbce..f6f5b56a 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -13,6 +13,7 @@ import type { SemanticIndexRuntime, CrossChatAnalysisService, SessionRuntimeAdapter, + AIMemoryService, } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import { getDefaultGeneralAssistantId, type ChartAutoMode } from '@openchatlab/shared-types' @@ -77,6 +78,8 @@ export function createElectronRunAgentStream( crossChatOptions?: { analysisService: CrossChatAnalysisService sessionAdapter: SessionRuntimeAdapter + memoryService: AIMemoryService + getAllowProactiveMemory: () => boolean } ): ( params: AgentStreamRequest, @@ -150,6 +153,9 @@ export function createElectronRunAgentStream( locale, preprocessConfig: params.preprocessConfig, maxToolResultTokens: resolveCrossChatToolResultTokenBudget(resolvedContextWindow), + memoryService: crossChatOptions.memoryService, + aiChatId, + allowProactiveMemory: crossChatOptions.getAllowProactiveMemory(), }) await runCrossChatAgent({ userMessage, @@ -161,6 +167,7 @@ export function createElectronRunAgentStream( apiKey: activeAIConfig.apiKey, tools, aiChatManager: getAIChatManager(), + memoryService: crossChatOptions.memoryService, onEvent, abortSignal, thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, diff --git a/apps/desktop/main/ai/cross-chat-tool-adapter.ts b/apps/desktop/main/ai/cross-chat-tool-adapter.ts index 9aeeb0a9..4b4a79b5 100644 --- a/apps/desktop/main/ai/cross-chat-tool-adapter.ts +++ b/apps/desktop/main/ai/cross-chat-tool-adapter.ts @@ -1,5 +1,6 @@ import type { AgentTool, + AIMemoryService, CrossChatAnalysisService, PreprocessConfig, SessionRuntimeAdapter, @@ -18,10 +19,16 @@ export function createElectronCrossChatTools(options: { locale?: string preprocessConfig?: Record maxToolResultTokens: number + memoryService: AIMemoryService + aiChatId: string + allowProactiveMemory: boolean }): AgentTool[] { const context: Omit = { locale: options.locale, analysisService: options.analysisService, + memoryService: options.memoryService, + aiChatId: options.aiChatId, + allowProactiveMemory: options.allowProactiveMemory, maxToolResultTokens: options.maxToolResultTokens, countTokens, preprocessMessagesBySession: (sessionId, messages) => diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index 9e4ee72c..707f90d7 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -29,6 +29,7 @@ import { createCrossChatAnalysisService, createGlobalInsightService, PreferencesManager, + AIMemoryService, } from '@openchatlab/node-runtime' import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' @@ -66,6 +67,7 @@ let endpoint: InternalEndpoint | null = null let dbManager: DatabaseManager | null = null let mergeCache: MergeSessionCache | null = null let semanticIndexService: SemanticIndexRuntime | null = null +let aiMemoryService: AIMemoryService | null = null const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB @@ -87,6 +89,7 @@ export async function startInternalServer( let newServer: FastifyInstance | null = null let newDbManager: DatabaseManager | null = null let newSemanticIndexService: SemanticIndexRuntime | null = null + let newAIMemoryService: AIMemoryService | null = null try { const token = `int_${randomBytes(32).toString('hex')}` @@ -117,6 +120,7 @@ export async function startInternalServer( }) const aiDataDir = pathProvider.getAiDataDir() + newAIMemoryService = new AIMemoryService(aiDataDir, { nativeBinding }) const llmRuntimeStores = getDesktopLlmRuntimeStores(aiDataDir) const { llmConfigStore, customProviderStore, customModelStore } = llmRuntimeStores appLogger.info('ai-config', 'Shared LLM configuration store initialized', { aiDataDir }) @@ -223,6 +227,8 @@ export async function startInternalServer( runAgentStream: createElectronRunAgentStream(llmConfigStore, newSemanticIndexService ?? undefined, { analysisService: crossChatAnalysisService, sessionAdapter, + memoryService: newAIMemoryService, + getAllowProactiveMemory: () => preferencesManager.load().aiGlobalSettings.allowProactiveMemory, }), executeAiTool: createExecuteElectronAiTool(newSemanticIndexService ?? undefined), } @@ -256,6 +262,7 @@ export async function startInternalServer( dbManager = newDbManager mergeCache = newMergeCache semanticIndexService = newSemanticIndexService + aiMemoryService = newAIMemoryService endpoint = { baseUrl: `http://127.0.0.1:${port}`, token } console.log(`[InternalAPI] Server started on port ${port}`) @@ -276,10 +283,16 @@ export async function startInternalServer( } catch { /* best-effort */ } + try { + newAIMemoryService?.close() + } catch { + /* best-effort */ + } server = null dbManager = null mergeCache = null semanticIndexService = null + aiMemoryService = null endpoint = null throw err } @@ -321,11 +334,17 @@ export async function stopInternalServer(): Promise { } catch { /* best-effort */ } + try { + aiMemoryService?.close() + } catch { + /* best-effort */ + } server = null endpoint = null dbManager = null mergeCache = null semanticIndexService = null + aiMemoryService = null console.log('[InternalAPI] Server stopped') } } diff --git a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts index f5d5e75a..a18dce16 100644 --- a/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts +++ b/packages/node-runtime/src/ai/__tests__/cross-chat-agent.test.ts @@ -1,7 +1,9 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' import type { Api as PiApi, Model as PiModel } from '@earendil-works/pi-ai' +import type { AIMemoryEntry } from '@openchatlab/shared-types' import type { AIChatManager } from '../chats' +import { buildGlobalMemoryPrompt, type AIMemoryService } from '../memory' import type { AgentStreamChunk } from '../agent/event-handler' import { buildCrossChatSystemPrompt, @@ -30,6 +32,9 @@ describe('cross-chat agent prompt', () => { 'search_messages_globally', 'get_cross_chat_message_context', 'get_cross_chat_overview', + 'memory_read', + 'memory_write', + 'memory_forget', ]) { assert.match(prompt, new RegExp(tool)) } @@ -74,7 +79,7 @@ describe('cross-chat agent prompt', () => { assert.doesNotMatch(prompt, /可以使用.*execute_sql/) }) - it('keeps the English prompt on the same ten-tool investigation contract', () => { + it('keeps the English prompt on the same thirteen-tool investigation contract', () => { const prompt = buildCrossChatSystemPrompt('en-US', new Date('2026-08-23T12:00:00Z')) for (const tool of [ 'resolve_chat_entities', @@ -87,6 +92,9 @@ describe('cross-chat agent prompt', () => { 'search_messages_globally', 'get_cross_chat_message_context', 'get_cross_chat_overview', + 'memory_read', + 'memory_write', + 'memory_forget', ]) { assert.match(prompt, new RegExp(tool)) } @@ -116,6 +124,32 @@ describe('cross-chat agent prompt', () => { assert.match(prompt, /Do not pass message-count, session-count, or execution-time budgets/) assert.doesNotMatch(prompt, /four tools/) }) + + it('injects ordered global memories within the fixed count and content budgets', () => { + const entries: AIMemoryEntry[] = Array.from({ length: 22 }, (_, index) => ({ + id: `memory-${String(index).padStart(2, '0')}`, + scopeType: 'global', + scopeId: null, + content: index === 0 ? '最近默认表示 90 天' : `偏好-${index}-${'x'.repeat(205)}`, + sourceType: index === 1 ? 'ai' : 'user', + sourceAIChatId: null, + sourceMessageId: null, + createdAt: 100 - index, + updatedAt: 100 - index, + })) + + const first = buildGlobalMemoryPrompt(entries, 'zh-CN') + const second = buildGlobalMemoryPrompt(entries, 'zh-CN') + assert.equal(first, second) + assert.match(first, /memory-00.*最近默认表示 90 天/) + assert.match(first, /memory-01.*source=ai/) + assert.match(first, /部分全局记忆未注入.*memory_read/) + assert.doesNotMatch(first, /memory-20/) + + const injected = buildCrossChatSystemPrompt('zh-CN', new Date('2026-08-23T12:00:00Z'), first) + assert.match(injected, /最近默认表示 90 天/) + assert.match(injected, /ai.*重新查询.*聊天证据/) + }) }) describe('cross-chat agent lifecycle', () => { @@ -136,6 +170,7 @@ describe('cross-chat agent lifecycle', () => { api: 'openai-completions', provider: 'test', } as unknown as PiModel + const memoryService = { list: () => [] } as unknown as AIMemoryService await runCrossChatAgent({ userMessage: '分析一下', @@ -144,6 +179,7 @@ describe('cross-chat agent lifecycle', () => { apiKey: 'test-key', tools: [], aiChatManager, + memoryService, abortSignal: controller.signal, onEvent: (event) => events.push(event), }) @@ -172,6 +208,7 @@ describe('cross-chat agent lifecycle', () => { api: 'openai-completions', provider: 'test', } as unknown as PiModel + const memoryService = { list: () => [] } as unknown as AIMemoryService await runCrossChatAgent({ userMessage: '你觉得这个方案怎么样?', @@ -181,6 +218,7 @@ describe('cross-chat agent lifecycle', () => { apiKey: 'test-key', tools: [], aiChatManager, + memoryService, abortSignal: controller.signal, onEvent: (event) => events.push(event), logger: { diff --git a/packages/node-runtime/src/ai/cross-chat-agent.ts b/packages/node-runtime/src/ai/cross-chat-agent.ts index 8ee91196..dee81928 100644 --- a/packages/node-runtime/src/ai/cross-chat-agent.ts +++ b/packages/node-runtime/src/ai/cross-chat-agent.ts @@ -6,6 +6,7 @@ import { DEFAULT_CONTEXT_COMPRESSION_CONFIG, checkAndCompress, createCompression import { createAiTranslate } from './i18n' import { initTokenizer } from './tokenizer' import type { AIChatManager } from './chats' +import { buildGlobalMemoryPrompt, type AIMemoryService } from './memory' import { AgentEventHandler, type AgentStreamChunk } from './agent/event-handler' import { appendEntityRefsForModel } from './agent/history' import { DEFAULT_MAX_TOOL_ROUNDS } from './agent/constants' @@ -30,6 +31,7 @@ export interface RunCrossChatAgentOptions { apiKey: string tools: AgentTool[] aiChatManager: AIChatManager + memoryService: Pick onEvent: (event: AgentStreamChunk) => void abortSignal?: AbortSignal thinkingLevel?: ThinkingLevel @@ -55,6 +57,7 @@ export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Prom apiKey, tools, aiChatManager, + memoryService, onEvent, abortSignal, thinkingLevel, @@ -66,7 +69,8 @@ export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Prom entityRefCount: entityRefs?.length ?? 0, toolCount: tools.length, }) - const systemPrompt = buildCrossChatSystemPrompt(locale) + const globalMemoryPrompt = buildGlobalMemoryPrompt(memoryService.list({ scopeType: 'global', scopeId: null }), locale) + const systemPrompt = buildCrossChatSystemPrompt(locale, new Date(), globalMemoryPrompt) const handler = new AgentEventHandler({ onChunk: onEvent, context: {}, systemPrompt }) let cachedMessages: PiMessage[] = [] @@ -201,7 +205,7 @@ export async function runCrossChatAgent(options: RunCrossChatAgentOptions): Prom } } -export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()): string { +export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date(), globalMemoryPrompt = ''): string { const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US' const currentDate = now.toLocaleDateString(dateLocale, { year: 'numeric', @@ -231,7 +235,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()): - 用户把自己作为事件主体(例如“我最近跟多少人聊过我买房”)时,第一次搜索必须传 sender=owner,把本人发言作为检索种子;不要先搜索所有人的同类话题。搜索工具会自动附带周边消息以识别真正参与对话的人;只有需要更深原文时才继续使用 get_cross_chat_message_context,上下文不限制为本人发言。 工具与结论规则: -- 你只有 resolve_chat_entities、read_recent_session、rank_private_contacts、rank_group_sessions、get_global_activity_summary、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview 十个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。 +- 你只有 resolve_chat_entities、read_recent_session、rank_private_contacts、rank_group_sessions、get_global_activity_summary、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview、memory_read、memory_write、memory_forget 十三个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。 - read_recent_session 是单个已解析会话的轻量近期回顾:它内部读取最新一小段原文和最多 5 个已有段落摘要,模型不能指定消息预算。不要仅因为 selection.hasEarlierMessages=true 就自动扩大搜索;先回答用户当前的普通近期回顾,只有用户要求具体时间、关键词、更早内容或深挖时再使用 search_messages_globally。 - inspect_contact_sessions 返回的是已导入会话中的本人发言和成员表结构事实,不返回聊天正文,也不能证明现实世界中的全部群聊。roster_only 只说明导入成员表记录过此人;存在 nextCursor 或 coverage 不完整时不能表述成全量结果。 - inspect_shared_interactions 的群名称、成员结构、共同活跃和相邻发言只是调查导航信号,不能直接证明同事、同学、亲属、朋友或亲密关系。优先对 direct_reply 和 proximity 锚点调用 get_cross_chat_message_context 读取原文;回复目标可能由 relatedMessageId 指向较远消息,必要时分别展开两个消息 ID。 @@ -245,7 +249,15 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()): - 涉及单个联系人的限时搜索返回 0 条时,不要只说“什么都没有”。必须从 resolve_chat_entities 返回的私聊 session 元数据中说明最近一条已导入私聊的具体时间,以及它是否落在查询窗口之外;没有私聊 session 或没有可用时间时也要明确说明。之后再询问用户是否扩大范围。 - sender=owner 时还要检查 coverage.ownerResolution;存在未设置或无法解析“我”的会话时,必须说明对应覆盖缺口,禁止通过昵称猜测本人。 - 引用证据时说明来源会话;不要泄露工具未返回的信息,也不要编造联系人、群聊或消息。 -- 如果问题不需要聊天数据,直接回答;如果现有十个工具不足,坦诚说明当前能力边界。 +- 如果问题不需要聊天数据,直接回答;如果现有十三个工具不足,坦诚说明当前能力边界。 + +长期记忆规则: +- 只保存跨 AI 对话仍有长期价值的用户偏好、身份背景、明确纠正,或已有聊天证据支持且会影响后续调查方向的稳定结论。不要保存本轮临时要求、普通统计、聊天原文、工具过程、普通回答或未经证据支持的猜测。 +- 联系人和群聊记忆必须先取得稳定 contactKey 或 group sessionId。对具体实体展开实质分析前先调用 memory_read;写入前也先读取同一作用域,已有相同含义时不要重复创建。 +- 用户明确提供、要求记住或纠正的内容使用 source_type=user;你根据聊天数据形成的结论使用 source_type=ai,并写清必要的观察时间和不确定性。 +- source_type=ai 的记忆只是调查线索,后续使用时必须重新查询当前聊天数据和原始聊天证据,不能把旧结论当作当前事实。 +- 用户纠正旧内容时按 memory ID 调用 memory_write 精确更新;确认失效或用户要求忘记时调用 memory_forget。没有真正长期价值时不要调用 memory_write。 +${globalMemoryPrompt ? `\n当前已注入的全局用户偏好:\n${globalMemoryPrompt}` : ''} 你可以使用工具。如果需要你没有的信息,请调用提供的函数。` } @@ -270,7 +282,7 @@ Data and scope rules: - When the user is the subject of the event, such as "who did I discuss my home purchase with", the first search must pass sender=owner and treat the owner's messages as discovery seeds. Search automatically includes surrounding messages to identify actual participants; call get_cross_chat_message_context only when deeper source text is needed. Surrounding context is not owner-only. Tool and conclusion rules: -- You only have resolve_chat_entities, read_recent_session, rank_private_contacts, rank_group_sessions, get_global_activity_summary, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, and get_cross_chat_overview. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps. +- You only have resolve_chat_entities, read_recent_session, rank_private_contacts, rank_group_sessions, get_global_activity_summary, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, get_cross_chat_overview, memory_read, memory_write, and memory_forget. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps. - read_recent_session is the lightweight recent-recap path for one resolved session. It internally reads a small newest message slice and up to five existing segment summaries; the model cannot set message budgets. Do not expand merely because selection.hasEarlierMessages is true. Answer the ordinary recent recap first, then use search_messages_globally only for explicit dates, keywords, earlier content, or deeper investigation. - inspect_contact_sessions returns the contact's own activity and imported member-roster facts, not message text or every real-world chat. roster_only only means that the imported member table records the person. Never describe a paged or incomplete result as exhaustive. - Group names, member structure, co-active days, and proximity from inspect_shared_interactions are investigation signals, not proof of colleague, classmate, family, friend, or intimate relationships. Expand direct_reply and proximity anchors with get_cross_chat_message_context; a distant reply target may require separately opening relatedMessageId. @@ -284,7 +296,15 @@ Tool and conclusion rules: - When a time-bounded search about one contact returns zero messages, do not merely say that nothing was found. From the direct private session metadata returned by resolve_chat_entities, state the latest imported private-chat timestamp as an exact date and whether it falls outside the requested window. Explicitly say when no private session or timestamp is available, then ask before widening the range. - For sender=owner, inspect coverage.ownerResolution and disclose sessions where the owner is missing or unresolved. Never guess the owner from display names. - Name the source session when citing evidence. Never invent people, sessions, or messages. -- Answer directly when no chat data is needed. If the ten tools are insufficient, explain the current limitation honestly. +- Answer directly when no chat data is needed. If the thirteen tools are insufficient, explain the current limitation honestly. + +Long-term memory rules: +- Save only preferences, identity/background facts, explicit corrections, or evidence-backed stable conclusions that remain useful across AI conversations. Never save temporary instructions, ordinary statistics, transcripts, tool traces, ordinary answers, or unsupported guesses. +- Obtain a stable contactKey or group sessionId before reading or writing entity memory. Call memory_read before substantive entity analysis and before writing that scope; do not create a duplicate with the same meaning. +- Use source_type=user only for facts the user explicitly provides, asks to remember, or corrects. Use source_type=ai for conclusions derived from chat data, including the observation period and uncertainty. +- Treat source_type=ai memory only as an investigation lead. Re-query current chat data and original chat evidence before using it as a fact. +- Update a corrected memory by stable memory ID with memory_write. Call memory_forget when the user asks to forget it or confirms it is invalid. Do not call memory_write when nothing has genuine long-term value. +${globalMemoryPrompt ? `\nInjected global user preferences:\n${globalMemoryPrompt}` : ''} You have access to tools. If you need information you don't have, use the provided functions.` } diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index dd413270..5c23cd45 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -47,7 +47,7 @@ export type { TokenUsageData, AIChatManagerLogger, } from './chats' -export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService } from './memory' +export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildGlobalMemoryPrompt } from './memory' export type { AIMemoryServiceOptions, CreateAIMemoryInput, UpdateAIMemoryInput } from './memory' // Tokenizer diff --git a/packages/node-runtime/src/ai/memory.ts b/packages/node-runtime/src/ai/memory.ts index 99442653..9a91d317 100644 --- a/packages/node-runtime/src/ai/memory.ts +++ b/packages/node-runtime/src/ai/memory.ts @@ -8,6 +8,27 @@ import { appLogger } from '../logging/app-logger' export const AI_MEMORY_CONTENT_MAX_CHARS = 2_000 const AI_MEMORY_SCHEMA_VERSION = 1 +export function buildGlobalMemoryPrompt(entries: AIMemoryEntry[], locale = 'zh-CN'): string { + const selected: AIMemoryEntry[] = [] + let contentChars = 0 + for (const entry of entries) { + if (selected.length >= 20 || contentChars + entry.content.length > 4_000) break + selected.push(entry) + contentChars += entry.content.length + } + if (selected.length === 0) return '' + + const lines = selected.map((entry) => `- [id=${entry.id}; source=${entry.sourceType}] ${entry.content}`) + if (selected.length < entries.length) { + lines.push( + locale.startsWith('zh') + ? '- 部分全局记忆未注入;需要时调用 memory_read 读取。' + : '- Some global memories were not injected; call memory_read when needed.' + ) + } + return lines.join('\n') +} + export interface CreateAIMemoryInput extends AIMemoryScope { content: string sourceType: AIMemorySourceType diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index fbeae91a..24101e63 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -191,7 +191,7 @@ export type { SkillDef, SkillSummary, } from './ai' -export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService } from './ai' +export { AI_MEMORY_CONTENT_MAX_CHARS, AIMemoryService, buildGlobalMemoryPrompt } from './ai' export type { AIMemoryServiceOptions, CreateAIMemoryInput, UpdateAIMemoryInput } from './ai' export { parseAssistantFile, diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts index 437d6218..4db1c39a 100644 --- a/packages/node-runtime/src/preferences.test.ts +++ b/packages/node-runtime/src/preferences.test.ts @@ -204,6 +204,7 @@ test('loads thinkingLevels as empty object when field is absent in legacy prefer // Existing fields must survive the migration assert.equal(loaded.aiGlobalSettings.maxMessagesPerRequest, 500) assert.equal(loaded.aiGlobalSettings.chartAutoMode, 'suggest') + assert.equal(loaded.aiGlobalSettings.allowProactiveMemory, true) } finally { rmSync(systemDir, { recursive: true, force: true }) } diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index 34cd95af..d1c640a1 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -49,6 +49,7 @@ const DEFAULTS: Preferences = { sqlExportFormat: 'csv', enableAutoSkill: true, chartAutoMode: 'suggest', + allowProactiveMemory: true, searchContextBefore: 2, searchContextAfter: 2, }, diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 6a3394cb..423e3b23 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -295,6 +295,7 @@ export interface AIGlobalSettings { sqlExportFormat: 'csv' | 'json' enableAutoSkill: boolean chartAutoMode: ChartAutoMode + allowProactiveMemory: boolean searchContextBefore: number searchContextAfter: number } diff --git a/packages/tools/src/agent-adapter.test.ts b/packages/tools/src/agent-adapter.test.ts index 288f3661..ea709469 100644 --- a/packages/tools/src/agent-adapter.test.ts +++ b/packages/tools/src/agent-adapter.test.ts @@ -47,7 +47,7 @@ function globalActivitySummary(): CrossChatGlobalActivitySummaryResult { } } -test('the shared Desktop and CLI Web adapter exposes and executes all ten cross-chat tools', async () => { +test('the shared Desktop and CLI Web adapter exposes and executes all thirteen cross-chat tools', async () => { const requests: CrossChatGlobalActivitySummaryRequest[] = [] const analysisService = { getGlobalActivitySummary(request: CrossChatGlobalActivitySummaryRequest) { @@ -58,13 +58,16 @@ test('the shared Desktop and CLI Web adapter exposes and executes all ten cross- const tools = createCrossChatAgentToolAdapters({ locale: 'zh-CN', analysisService, + memoryService: {} as Parameters[0]['memoryService'], + aiChatId: 'global-chat-test', + allowProactiveMemory: true, maxToolResultTokens: 16_000, preprocessMessagesBySession: async (_sessionId, messages) => messages, preprocessSummariesBySession: async (_sessionId, summaries) => summaries, preprocessModelLabel: (value) => value, }) - assert.equal(tools.length, 10) + assert.equal(tools.length, 13) const summaryTool = tools.find((tool) => tool.name === 'get_global_activity_summary') assert.ok(summaryTool) assert.deepEqual(Object.keys(summaryTool.parameters.properties), ['mode', 'year']) diff --git a/packages/tools/src/definitions/cross-chat-tools.test.ts b/packages/tools/src/definitions/cross-chat-tools.test.ts index feb6674f..8429b5d9 100644 --- a/packages/tools/src/definitions/cross-chat-tools.test.ts +++ b/packages/tools/src/definitions/cross-chat-tools.test.ts @@ -497,6 +497,9 @@ function createContext(overrides: Partial = {}): C return { locale: 'zh-CN', analysisService: service, + memoryService: {} as CrossChatToolExecutionContext['memoryService'], + aiChatId: 'global-chat-test', + allowProactiveMemory: true, preprocessMessagesBySession: (_sessionId, messages) => messages.map((message) => ({ ...message, senderName: 'U1', content: '[redacted]' })), preprocessSummariesBySession: (_sessionId, summaries) => summaries, @@ -505,7 +508,7 @@ function createContext(overrides: Partial = {}): C } describe('cross-chat agent registry', () => { - it('contains only the ten dedicated tools and is isolated from session and MCP registries', () => { + it('contains only the thirteen dedicated tools and is isolated from session and MCP registries', () => { const names = CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => tool.name) assert.deepEqual(names, [ 'resolve_chat_entities', @@ -518,6 +521,9 @@ describe('cross-chat agent registry', () => { 'get_global_activity_summary', 'inspect_contact_sessions', 'inspect_shared_interactions', + 'memory_read', + 'memory_write', + 'memory_forget', ]) for (const name of names) { assert.equal( @@ -608,7 +614,7 @@ describe('cross-chat agent registry', () => { assert.equal(typeof captured?.endTs, 'number') assert.equal(captured?.maxSessions, undefined) assert.equal(captured?.maxWallTimeMs, undefined) - assert.deepEqual(result.data?.appliedRange, { + assert.deepEqual((result.data as CrossChatOverviewResult).appliedRange, { startTs: captured?.startTs ?? null, endTs: captured?.endTs ?? null, dataEarliestMessageTs: 100, @@ -710,7 +716,7 @@ describe('cross-chat agent registry', () => { assert.equal(captured?.mode, 'total_activity') assert.equal(captured?.recentDays, 30) assert.equal(captured?.limit, 8) - await assert.rejects(() => tool.handler({ mode: 'unknown' }, context), /mode must be/) + await assert.rejects(async () => tool.handler({ mode: 'unknown' }, context), /mode must be/) }) it('preprocesses model-visible names in rankings and overview while retaining local details', async () => { @@ -867,7 +873,8 @@ describe('cross-chat agent registry', () => { assert.equal(content.selection.hasEarlierMessages, true) assert.equal(content.messages[0].content, '[redacted]') assert.equal(content.summaries[0].summary, 'Recent topic summary') - assert.equal(result.data?.crossChatEvidence.sources[0].snippet, '[redacted]') + const details = result.data as { crossChatEvidence: { sources: Array<{ snippet: string }> } } + assert.equal(details.crossChatEvidence.sources[0]?.snippet, '[redacted]') }) it('uses only privacy-processed summaries in recent session tool content', async () => { @@ -923,9 +930,14 @@ describe('cross-chat agent registry', () => { assert.equal(content.source.sessionName, 'Session1') assert.equal(content.messages[0].sessionName, 'Session1') assert.equal(result.content.includes('Session A'), false) - assert.equal(result.data?.source.sessionName, 'Session A') - assert.equal(result.data?.messages[0].sessionName, 'Session session-a') - assert.equal(result.data?.crossChatEvidence.sources[0].sessionName, 'Session session-a') + const details = result.data as { + source: { sessionName: string } + messages: Array<{ sessionName: string }> + crossChatEvidence: { sources: Array<{ sessionName: string }> } + } + assert.equal(details.source.sessionName, 'Session A') + assert.equal(details.messages[0]?.sessionName, 'Session session-a') + assert.equal(details.crossChatEvidence.sources[0]?.sessionName, 'Session session-a') }) it('keeps latest private-chat recaps outside the default thirty-day window', () => { diff --git a/packages/tools/src/definitions/memory-tools.test.ts b/packages/tools/src/definitions/memory-tools.test.ts new file mode 100644 index 00000000..e7699064 --- /dev/null +++ b/packages/tools/src/definitions/memory-tools.test.ts @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { AIMemoryEntry, AIMemoryScope, AIEntityRef, CrossChatEntityResolution } from '@openchatlab/shared-types' +import { ChatType } from '@openchatlab/shared-types' +import { AGENT_TOOL_REGISTRY, CROSS_CHAT_AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY } from '../registry' +import type { AIMemoryToolService, CrossChatAnalysisToolService, CrossChatToolExecutionContext } from '../types' +import { memoryForgetTool, memoryReadTool, memoryWriteTool } from './memory-tools' + +class FakeMemoryService implements AIMemoryToolService { + entries: AIMemoryEntry[] = [] + nextId = 1 + + list(scope?: AIMemoryScope): AIMemoryEntry[] { + const matches = scope + ? this.entries.filter((entry) => entry.scopeType === scope.scopeType && entry.scopeId === scope.scopeId) + : this.entries + return [...matches].sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id)) + } + + get(id: string): AIMemoryEntry | null { + return this.entries.find((entry) => entry.id === id) ?? null + } + + create(input: Parameters[0]): AIMemoryEntry { + const entry: AIMemoryEntry = { + ...input, + id: `memory-${this.nextId++}`, + sourceAIChatId: input.sourceAIChatId ?? null, + sourceMessageId: input.sourceMessageId ?? null, + createdAt: this.nextId, + updatedAt: this.nextId, + } + this.entries.push(entry) + return entry + } + + update(id: string, input: Parameters[1]): AIMemoryEntry { + const current = this.get(id) + if (!current) throw new Error('not found') + const updated = { ...current, ...input, updatedAt: current.updatedAt + 10 } + this.entries = this.entries.map((entry) => (entry.id === id ? updated : entry)) + return updated + } + + forget(id: string): boolean { + const before = this.entries.length + this.entries = this.entries.filter((entry) => entry.id !== id) + return this.entries.length < before + } +} + +function entityResolution(refs: AIEntityRef[]): CrossChatEntityResolution { + const contacts = refs + .filter((ref): ref is Extract => ref.type === 'contact') + .map((ref) => ({ + ref, + status: ref.contactKey === 'contact-1' ? ('resolved' as const) : ('unresolved' as const), + cacheStatus: 'fresh' as const, + sessions: [], + unresolvedSessionIds: [], + failedSessionIds: [], + })) + const sessions = refs + .filter((ref): ref is Extract => ref.type === 'session') + .map((ref) => ({ + ref, + status: + ref.sessionId === 'group-1' && ref.sessionType === ChatType.GROUP + ? ('resolved' as const) + : ('unresolved' as const), + session: + ref.sessionId === 'group-1' && ref.sessionType === ChatType.GROUP + ? { + sessionId: ref.sessionId, + sessionName: ref.displayName, + sessionType: ChatType.GROUP, + platform: 'test', + lastMessageTs: null, + } + : undefined, + })) + const resolvedEntities = + contacts.filter((item) => item.status === 'resolved').length + + sessions.filter((item) => item.status === 'resolved').length + return { + contacts, + sessions, + unresolved: [], + coverage: { + requestedEntities: refs.length, + resolvedEntities, + candidateSessions: 0, + resolvedSessions: sessions.filter((item) => item.status === 'resolved').length, + failedSessions: 0, + }, + } +} + +function createContext(options: { allowProactiveMemory?: boolean } = {}): CrossChatToolExecutionContext { + const analysisService = { + resolveEntities: async (refs: AIEntityRef[]) => entityResolution(refs), + } as unknown as CrossChatAnalysisToolService + return { + locale: 'zh-CN', + analysisService, + memoryService: new FakeMemoryService(), + aiChatId: 'global-chat-1', + allowProactiveMemory: options.allowProactiveMemory ?? true, + preprocessMessagesBySession: (_sessionId, messages) => messages, + preprocessSummariesBySession: (_sessionId, summaries) => summaries, + preprocessModelLabel: (value) => value, + } +} + +describe('global memory tool registry', () => { + it('registers all three tools only in the global Agent registry', () => { + const names = [memoryReadTool.name, memoryWriteTool.name, memoryForgetTool.name] + assert.deepEqual(names, ['memory_read', 'memory_write', 'memory_forget']) + for (const name of names) { + assert.equal( + CROSS_CHAT_AGENT_TOOL_REGISTRY.some((tool) => tool.name === name), + true + ) + assert.equal( + AGENT_TOOL_REGISTRY.some((tool) => tool.name === name), + false + ) + assert.equal( + MCP_TOOL_REGISTRY.some((tool) => tool.name === name), + false + ) + } + }) +}) + +describe('global memory tools', () => { + it('writes user memory with runtime-owned source metadata and updates only the same scope', async () => { + const context = createContext() + const created = await memoryWriteTool.handler( + { + scope_type: 'contact', + scope_id: 'contact-1', + content: ' 用户说这是大学同学 ', + source_type: 'user', + }, + context + ) + const createdEntry = created.data as AIMemoryEntry + assert.equal(createdEntry.content, '用户说这是大学同学') + assert.equal(createdEntry.sourceAIChatId, 'global-chat-1') + + const updated = await memoryWriteTool.handler( + { + id: createdEntry.id, + scope_type: 'contact', + scope_id: 'contact-1', + content: '用户纠正为高中同学', + source_type: 'user', + }, + context + ) + assert.equal((updated.data as AIMemoryEntry).content, '用户纠正为高中同学') + + await assert.rejects( + async () => + memoryWriteTool.handler( + { + id: createdEntry.id, + scope_type: 'group', + scope_id: 'group-1', + content: '错误搬迁', + source_type: 'user', + }, + context + ), + /scope/i + ) + }) + + it('blocks proactive AI writes when disabled but keeps explicit user writes available', async () => { + const context = createContext({ allowProactiveMemory: false }) + await assert.rejects( + async () => + memoryWriteTool.handler({ scope_type: 'global', content: 'AI inference', source_type: 'ai' }, context), + /disabled/i + ) + + const result = await memoryWriteTool.handler( + { scope_type: 'global', content: '用户明确要求记住', source_type: 'user' }, + context + ) + assert.equal((result.data as AIMemoryEntry).sourceType, 'user') + }) + + it('requires resolvable stable ids for entity scopes', async () => { + const context = createContext() + await assert.rejects( + async () => memoryReadTool.handler({ scope_type: 'contact', scope_id: 'Alice' }, context), + /stable contact/i + ) + await assert.rejects( + async () => + memoryWriteTool.handler( + { scope_type: 'group', scope_id: 'missing-group', content: 'x', source_type: 'user' }, + context + ), + /stable group/i + ) + }) + + it('returns bounded reads with AI verification guidance and forgets by stable id', async () => { + const context = createContext() + const memoryService = context.memoryService as FakeMemoryService + for (let index = 0; index < 3; index += 1) { + memoryService.create({ + scopeType: 'global', + scopeId: null, + content: `memory-${index}`, + sourceType: index === 2 ? 'ai' : 'user', + }) + } + + const read = await memoryReadTool.handler({ scope_type: 'global', limit: 2 }, context) + assert.equal((read.data as { entries: AIMemoryEntry[] }).entries.length, 2) + assert.equal((read.data as { truncated: boolean }).truncated, true) + assert.match(read.content, /重新查询原始聊天证据/) + + const id = (read.data as { entries: AIMemoryEntry[] }).entries[0]!.id + const forgotten = await memoryForgetTool.handler({ id }, context) + assert.deepEqual(forgotten.data, { id, deleted: true }) + assert.equal(memoryService.get(id), null) + }) +}) diff --git a/packages/tools/src/definitions/memory-tools.ts b/packages/tools/src/definitions/memory-tools.ts new file mode 100644 index 00000000..3e1185c4 --- /dev/null +++ b/packages/tools/src/definitions/memory-tools.ts @@ -0,0 +1,203 @@ +import type { AIMemoryEntry, AIMemoryScope, AIMemorySourceType, AIEntityRef } from '@openchatlab/shared-types' +import { ChatType } from '@openchatlab/shared-types' +import type { CrossChatToolExecutionContext, JsonSchema, ToolDefinition, ToolResult } from '../types' + +const readSchema: JsonSchema = { + type: 'object', + properties: { + scope_type: { type: 'string', enum: ['global', 'contact', 'group'] }, + scope_id: { + type: 'string', + description: 'Stable contactKey or group sessionId. Omit for global memory.', + }, + limit: { type: 'number', minimum: 1, maximum: 50, default: 20 }, + }, + required: ['scope_type'], +} + +const writeSchema: JsonSchema = { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable memory ID. Provide it to update an existing memory.' }, + scope_type: { type: 'string', enum: ['global', 'contact', 'group'] }, + scope_id: { + type: 'string', + description: 'Stable contactKey or group sessionId. Omit for global memory.', + }, + content: { type: 'string', description: 'One durable fact or preference, without chat transcripts.' }, + source_type: { + type: 'string', + enum: ['user', 'ai'], + description: 'Use user only for explicit user-provided facts or corrections; use ai for derived conclusions.', + }, + }, + required: ['scope_type', 'content', 'source_type'], +} + +const forgetSchema: JsonSchema = { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable memory ID returned by memory_read or memory_write.' }, + }, + required: ['id'], +} + +export const memoryReadTool: ToolDefinition = { + name: 'memory_read', + description: + 'Read durable global preferences or memories for one resolved contact/group. AI-derived memories are leads and must be re-verified against current chat evidence.', + inputSchema: readSchema, + handler: memoryReadHandler, +} + +export const memoryWriteTool: ToolDefinition = { + name: 'memory_write', + description: + 'Create or update one durable memory. Read the target scope first, use stable entity IDs, and never store transcripts, temporary requests, or ordinary statistics.', + inputSchema: writeSchema, + executionMode: 'sequential', + handler: memoryWriteHandler, +} + +export const memoryForgetTool: ToolDefinition = { + name: 'memory_forget', + description: + 'Permanently delete one memory by its stable ID after the user asks to forget or confirms it is invalid.', + inputSchema: forgetSchema, + executionMode: 'sequential', + handler: memoryForgetHandler, +} + +async function memoryReadHandler( + params: Record, + context: CrossChatToolExecutionContext +): Promise { + const scope = parseScope(params) + await assertResolvableEntityScope(scope, context) + const limit = parseLimit(params.limit) + const allEntries = context.memoryService.list(scope) + const entries = allEntries.slice(0, limit) + const truncated = allEntries.length > entries.length + const verificationGuidance = entries.some((entry) => entry.sourceType === 'ai') + ? context.locale?.startsWith('zh') + ? '标记为 ai 的记忆只是调查线索,使用前必须重新查询原始聊天证据。' + : 'Memories marked ai are investigation leads. Re-query the original chat evidence before using them.' + : null + const data = { entries, truncated, verificationGuidance } + return { content: JSON.stringify(data), data } +} + +async function memoryWriteHandler( + params: Record, + context: CrossChatToolExecutionContext +): Promise { + const scope = parseScope(params) + await assertResolvableEntityScope(scope, context) + const content = requireString(params.content, 'content') + const sourceType = parseSourceType(params.source_type) + if (sourceType === 'ai' && !context.allowProactiveMemory) { + throw new Error('Proactive AI memory is disabled; only explicit user memory writes are allowed') + } + + const id = optionalString(params.id) + let entry: AIMemoryEntry + if (id) { + const current = context.memoryService.get(id) + if (!current) throw new Error(`AI memory not found: ${id}`) + if (current.scopeType !== scope.scopeType || current.scopeId !== scope.scopeId) { + throw new Error('AI memory scope does not match the existing memory') + } + entry = context.memoryService.update(id, { + content, + sourceType, + sourceAIChatId: context.aiChatId, + sourceMessageId: null, + }) + } else { + entry = context.memoryService.create({ + ...scope, + content, + sourceType, + sourceAIChatId: context.aiChatId, + sourceMessageId: null, + }) + } + + return { content: JSON.stringify(entry), data: entry } +} + +async function memoryForgetHandler( + params: Record, + context: CrossChatToolExecutionContext +): Promise { + const id = requireString(params.id, 'id') + const data = { id, deleted: context.memoryService.forget(id) } + return { content: JSON.stringify(data), data } +} + +function parseScope(params: Record): AIMemoryScope { + const scopeType = params.scope_type + if (scopeType !== 'global' && scopeType !== 'contact' && scopeType !== 'group') { + throw new Error('scope_type must be global, contact, or group') + } + const scopeId = optionalString(params.scope_id) + if (scopeType === 'global') { + if (scopeId) throw new Error('Global memory does not accept scope_id') + return { scopeType, scopeId: null } + } + if (!scopeId) throw new Error(`${scopeType} memory requires a stable scope_id`) + return { scopeType, scopeId } +} + +function parseSourceType(value: unknown): AIMemorySourceType { + if (value !== 'user' && value !== 'ai') throw new Error('source_type must be user or ai') + return value +} + +function parseLimit(value: unknown): number { + if (value === undefined) return 20 + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 50) { + throw new Error('limit must be an integer between 1 and 50') + } + return value +} + +function requireString(value: unknown, field: string): string { + const normalized = optionalString(value) + if (!normalized) throw new Error(`${field} is required`) + return normalized +} + +function optionalString(value: unknown): string | null { + if (typeof value !== 'string') return null + return value.trim() || null +} + +async function assertResolvableEntityScope( + scope: AIMemoryScope, + context: CrossChatToolExecutionContext +): Promise { + if (scope.scopeType === 'global') return + + const ref: AIEntityRef = + scope.scopeType === 'contact' + ? { type: 'contact', contactKey: scope.scopeId!, displayName: scope.scopeId! } + : { type: 'session', sessionId: scope.scopeId!, displayName: scope.scopeId!, sessionType: ChatType.GROUP } + const resolution = await context.analysisService.resolveEntities([ref], { + signal: context.abortSignal, + }) + + if (scope.scopeType === 'contact') { + const resolved = resolution.contacts.some( + (item) => item.ref.contactKey === scope.scopeId && (item.status === 'resolved' || item.status === 'partial') + ) + if (!resolved) throw new Error('scope_id must be a resolvable stable contact ID') + return + } + + const resolved = resolution.sessions.some( + (item) => + item.ref.sessionId === scope.scopeId && item.status === 'resolved' && item.session?.sessionType === ChatType.GROUP + ) + if (!resolved) throw new Error('scope_id must be a resolvable stable group ID') +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 76331064..a14df59f 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -52,6 +52,7 @@ export { resolveChatEntitiesTool, searchMessagesGloballyTool, } from './definitions/cross-chat-tools' +export { memoryForgetTool, memoryReadTool, memoryWriteTool } from './definitions/memory-tools' // === SQL Tools === export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql' @@ -65,6 +66,7 @@ export type { ToolDefinition, ToolExecutionContext, CrossChatAnalysisToolService, + AIMemoryToolService, CrossChatToolExecutionContext, ToolProgress, ToolResult, diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index 2ffcca7d..0b1a3f8d 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -42,6 +42,7 @@ import { resolveChatEntitiesTool, searchMessagesGloballyTool, } from './definitions/cross-chat-tools' +import { memoryForgetTool, memoryReadTool, memoryWriteTool } from './definitions/memory-tools' import { SQL_TOOL_DEFS, createAllSqlToolDefinitions } from './sql' /** @@ -99,6 +100,9 @@ export const CROSS_CHAT_AGENT_TOOL_REGISTRY = [ getGlobalActivitySummaryTool, inspectContactSessionsTool, inspectSharedInteractionsTool, + memoryReadTool, + memoryWriteTool, + memoryForgetTool, ] /** 语义检索工具名(runner 动态过滤用) */ diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 50f092b5..271d863e 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -7,6 +7,9 @@ import type { ChartPayload, DatabaseAdapter, EvidenceTimeRangeMs } from '@openchatlab/core' import type { AIEntityRef, + AIMemoryEntry, + AIMemoryScope, + AIMemorySourceType, CrossChatContactLookupResult, CrossChatContactSessionsRequest, CrossChatContactSessionsResult, @@ -393,6 +396,29 @@ export interface CrossChatAnalysisToolService { getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise } +export interface AIMemoryToolService { + list(scope?: AIMemoryScope): AIMemoryEntry[] + get(id: string): AIMemoryEntry | null + create( + input: AIMemoryScope & { + content: string + sourceType: AIMemorySourceType + sourceAIChatId?: string | null + sourceMessageId?: string | null + } + ): AIMemoryEntry + update( + id: string, + input: { + content: string + sourceType: AIMemorySourceType + sourceAIChatId?: string | null + sourceMessageId?: string | null + } + ): AIMemoryEntry + forget(id: string): boolean +} + export interface CrossChatToolExecutionContext { locale?: string abortSignal?: AbortSignal @@ -401,6 +427,9 @@ export interface CrossChatToolExecutionContext { /** 平台注入的统一 tokenizer;未注入时 handler 使用 CJK-aware 轻量估算。 */ countTokens?: (text: string) => number analysisService: CrossChatAnalysisToolService + memoryService: AIMemoryToolService + aiChatId: string + allowProactiveMemory: boolean preprocessMessagesBySession: ( sessionId: string, messages: CrossChatMessageSource[] diff --git a/src/stores/prompt.ts b/src/stores/prompt.ts index 77947837..208de44b 100644 --- a/src/stores/prompt.ts +++ b/src/stores/prompt.ts @@ -10,6 +10,7 @@ interface AIGlobalSettings { sqlExportFormat: 'csv' | 'json' enableAutoSkill: boolean chartAutoMode: ChartAutoMode + allowProactiveMemory: boolean searchContextBefore: number searchContextAfter: number } @@ -29,6 +30,7 @@ export const usePromptStore = defineStore( sqlExportFormat: 'csv', enableAutoSkill: true, chartAutoMode: 'suggest', + allowProactiveMemory: true, searchContextBefore: 2, searchContextAfter: 2, }) diff --git a/tests/global-ai-memory-loop.test.ts b/tests/global-ai-memory-loop.test.ts new file mode 100644 index 00000000..650bcec9 --- /dev/null +++ b/tests/global-ai-memory-loop.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { it } from 'node:test' +import { AIMemoryService, buildGlobalMemoryPrompt } from '../packages/node-runtime/src/ai/memory' +import type { CrossChatSearchRequest, CrossChatSearchResult } from '@openchatlab/shared-types' +import { ChatType } from '@openchatlab/shared-types' +import { + memoryReadTool, + memoryWriteTool, + searchMessagesGloballyTool, + type CrossChatAnalysisToolService, + type CrossChatToolExecutionContext, +} from '../packages/tools/src' + +const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + +it('recalls a durable preference in a new global AI conversation before querying chat data', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-global-memory-loop-')) + const memoryService = new AIMemoryService(dir, { nativeBinding: sqliteNativeBinding }) + let capturedSearch: CrossChatSearchRequest | null = null + const analysisService = { + searchMessages: async (request: CrossChatSearchRequest): Promise => { + capturedSearch = request + return { + messages: [ + { + sessionId: 'private-1', + sessionName: 'Alice', + sessionType: ChatType.PRIVATE, + platform: 'test', + lastMessageTs: 100, + messageId: 1, + senderId: 1, + senderName: 'Me', + senderPlatformId: 'owner', + content: '我最近在看房', + timestamp: 100, + messageType: 0, + }, + ], + totalMatches: 1, + appliedFilters: { startTs: null, endTs: null, recentDays: request.recentDays ?? null, sender: 'owner' }, + coverage: { + candidateSessions: 1, + scannedSessions: 1, + matchedSessions: 1, + failedSessions: 0, + truncated: false, + truncatedReasons: [], + }, + } + }, + } as unknown as CrossChatAnalysisToolService + + const createContext = (aiChatId: string): CrossChatToolExecutionContext => ({ + locale: 'zh-CN', + analysisService, + memoryService, + aiChatId, + allowProactiveMemory: true, + preprocessMessagesBySession: (_sessionId, messages) => messages, + preprocessSummariesBySession: (_sessionId, summaries) => summaries, + preprocessModelLabel: (value) => value, + }) + + try { + await memoryWriteTool.handler( + { + scope_type: 'global', + content: '用户所说的“最近”默认指最近 90 天', + source_type: 'user', + }, + createContext('global-chat-write') + ) + + const prompt = buildGlobalMemoryPrompt(memoryService.list({ scopeType: 'global', scopeId: null }), 'zh-CN') + assert.match(prompt, /最近 90 天/) + + const secondConversation = createContext('global-chat-read') + const recalled = await memoryReadTool.handler({ scope_type: 'global' }, secondConversation) + const rememberedDays = Number(recalled.content.match(/最近 (\d+) 天/)?.[1]) + assert.equal(rememberedDays, 90) + + await searchMessagesGloballyTool.handler( + { keywords: ['买房'], recent_days: rememberedDays, sender: 'owner' }, + secondConversation + ) + assert.equal(capturedSearch?.recentDays, 90) + assert.equal(capturedSearch?.sender, 'owner') + } finally { + memoryService.close() + rmSync(dir, { recursive: true, force: true }) + } +})