feat(ai): integrate memory with global agent

This commit is contained in:
digua committed 2026-08-24 22:35:47 +08:00
1 parent bdc61d1b10
commit ed8ab84b4b
23 files changed
+741 -19

No files matched your search

+8 -1
View File
@@ -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) =>
+9
View File
@@ -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) =>
+19
View File
@@ -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.`
}
+1 -1
View File
@@ -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
+21
View File
@@ -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
+1 -1
View File
@@ -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 })
}
+1
View File
@@ -49,6 +49,7 @@ const DEFAULTS: Preferences = {
sqlExportFormat: 'csv',
enableAutoSkill: true,
chartAutoMode: 'suggest',
allowProactiveMemory: true,
searchContextBefore: 2,
searchContextAfter: 2,
},
+1
View File
@@ -295,6 +295,7 @@ export interface AIGlobalSettings {
sqlExportFormat: 'csv' | 'json'
enableAutoSkill: boolean
chartAutoMode: ChartAutoMode
allowProactiveMemory: boolean
searchContextBefore: number
searchContextAfter: number
}
+5 -2
View File
@@ -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')
}
+2
View File
@@ -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,
+4
View File
@@ -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 动态过滤用) */
+29
View File
@@ -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[]
+2
View File
@@ -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,
})
+96
View File
@@ -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 })
}
})