mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): integrate memory with global agent
This commit is contained in:
1 parent
bdc61d1b10
commit
ed8ab84b4b
23 files changed
+741
-19
No files matched your search
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentTool,
|
||||
AIMemoryService,
|
||||
CrossChatAnalysisService,
|
||||
PreprocessConfig,
|
||||
SessionRuntimeAdapter,
|
||||
@@ -18,10 +19,16 @@ export function createCliCrossChatTools(options: {
|
||||
locale?: string
|
||||
preprocessConfig?: Record<string, unknown>
|
||||
maxToolResultTokens: number
|
||||
memoryService: AIMemoryService
|
||||
aiChatId: string
|
||||
allowProactiveMemory: boolean
|
||||
}): AgentTool<any, any>[] {
|
||||
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
|
||||
locale: options.locale,
|
||||
analysisService: options.analysisService,
|
||||
memoryService: options.memoryService,
|
||||
aiChatId: options.aiChatId,
|
||||
allowProactiveMemory: options.allowProactiveMemory,
|
||||
maxToolResultTokens: options.maxToolResultTokens,
|
||||
countTokens,
|
||||
preprocessMessagesBySession: (sessionId, messages) =>
|
||||
|
||||
@@ -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<void> {
|
||||
aiChatManager.close()
|
||||
aiChatManager = null
|
||||
}
|
||||
if (aiMemoryService) {
|
||||
aiMemoryService.close()
|
||||
aiMemoryService = null
|
||||
}
|
||||
if (dbManager) {
|
||||
dbManager.closeAll()
|
||||
dbManager = null
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentTool,
|
||||
AIMemoryService,
|
||||
CrossChatAnalysisService,
|
||||
PreprocessConfig,
|
||||
SessionRuntimeAdapter,
|
||||
@@ -18,10 +19,16 @@ export function createElectronCrossChatTools(options: {
|
||||
locale?: string
|
||||
preprocessConfig?: Record<string, unknown>
|
||||
maxToolResultTokens: number
|
||||
memoryService: AIMemoryService
|
||||
aiChatId: string
|
||||
allowProactiveMemory: boolean
|
||||
}): AgentTool<any, any>[] {
|
||||
const context: Omit<CrossChatToolExecutionContext, 'abortSignal' | 'reportProgress'> = {
|
||||
locale: options.locale,
|
||||
analysisService: options.analysisService,
|
||||
memoryService: options.memoryService,
|
||||
aiChatId: options.aiChatId,
|
||||
allowProactiveMemory: options.allowProactiveMemory,
|
||||
maxToolResultTokens: options.maxToolResultTokens,
|
||||
countTokens,
|
||||
preprocessMessagesBySession: (sessionId, messages) =>
|
||||
|
||||
@@ -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<void> {
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PiApi>
|
||||
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<PiApi>
|
||||
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: {
|
||||
|
||||
@@ -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<AIMemoryService, 'list'>
|
||||
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.`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const DEFAULTS: Preferences = {
|
||||
sqlExportFormat: 'csv',
|
||||
enableAutoSkill: true,
|
||||
chartAutoMode: 'suggest',
|
||||
allowProactiveMemory: true,
|
||||
searchContextBefore: 2,
|
||||
searchContextAfter: 2,
|
||||
},
|
||||
|
||||
@@ -295,6 +295,7 @@ export interface AIGlobalSettings {
|
||||
sqlExportFormat: 'csv' | 'json'
|
||||
enableAutoSkill: boolean
|
||||
chartAutoMode: ChartAutoMode
|
||||
allowProactiveMemory: boolean
|
||||
searchContextBefore: number
|
||||
searchContextAfter: number
|
||||
}
|
||||
|
||||
@@ -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<typeof createCrossChatAgentToolAdapters>[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'])
|
||||
|
||||
@@ -497,6 +497,9 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): 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<CrossChatAnalysisToolService> = {}): 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', () => {
|
||||
|
||||
@@ -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<AIMemoryToolService['create']>[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<AIMemoryToolService['update']>[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<AIEntityRef, { type: 'contact' }> => 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<AIEntityRef, { type: 'session' }> => 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)
|
||||
})
|
||||
})
|
||||
@@ -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<CrossChatToolExecutionContext> = {
|
||||
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<CrossChatToolExecutionContext> = {
|
||||
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<CrossChatToolExecutionContext> = {
|
||||
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<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
const id = requireString(params.id, 'id')
|
||||
const data = { id, deleted: context.memoryService.forget(id) }
|
||||
return { content: JSON.stringify(data), data }
|
||||
}
|
||||
|
||||
function parseScope(params: Record<string, unknown>): 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<void> {
|
||||
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')
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 动态过滤用) */
|
||||
|
||||
@@ -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<CrossChatOverviewResult>
|
||||
}
|
||||
|
||||
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[]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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<CrossChatSearchResult> => {
|
||||
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 })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user