diff --git a/apps/cli/src/ai/cross-chat-tool-adapter.ts b/apps/cli/src/ai/cross-chat-tool-adapter.ts index e64a00f9..0510241c 100644 --- a/apps/cli/src/ai/cross-chat-tool-adapter.ts +++ b/apps/cli/src/ai/cross-chat-tool-adapter.ts @@ -4,7 +4,12 @@ import type { PreprocessConfig, SessionRuntimeAdapter, } from '@openchatlab/node-runtime' -import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime' +import { + countTokens, + preprocessCrossChatLabel, + preprocessCrossChatMessages, + preprocessCrossChatSummaries, +} from '@openchatlab/node-runtime' import { createCrossChatAgentToolAdapters, type CrossChatToolExecutionContext } from '@openchatlab/tools' export function createCliCrossChatTools(options: { @@ -26,6 +31,15 @@ export function createCliCrossChatTools(options: { messages, options.preprocessConfig as PreprocessConfig | undefined ), + preprocessSummariesBySession: (sessionId, summaries) => + preprocessCrossChatSummaries( + options.sessionAdapter, + sessionId, + summaries, + options.preprocessConfig as PreprocessConfig | undefined + ), + preprocessModelLabel: (value, pseudonym) => + preprocessCrossChatLabel(value, pseudonym, options.preprocessConfig as PreprocessConfig | undefined), } return createCrossChatAgentToolAdapters(context) as AgentTool[] diff --git a/apps/desktop/main/ai/cross-chat-tool-adapter.ts b/apps/desktop/main/ai/cross-chat-tool-adapter.ts index f75e3bc8..9aeeb0a9 100644 --- a/apps/desktop/main/ai/cross-chat-tool-adapter.ts +++ b/apps/desktop/main/ai/cross-chat-tool-adapter.ts @@ -4,7 +4,12 @@ import type { PreprocessConfig, SessionRuntimeAdapter, } from '@openchatlab/node-runtime' -import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime' +import { + countTokens, + preprocessCrossChatLabel, + preprocessCrossChatMessages, + preprocessCrossChatSummaries, +} from '@openchatlab/node-runtime' import { createCrossChatAgentToolAdapters, type CrossChatToolExecutionContext } from '@openchatlab/tools' export function createElectronCrossChatTools(options: { @@ -26,6 +31,15 @@ export function createElectronCrossChatTools(options: { messages, options.preprocessConfig as PreprocessConfig | undefined ), + preprocessSummariesBySession: (sessionId, summaries) => + preprocessCrossChatSummaries( + options.sessionAdapter, + sessionId, + summaries, + options.preprocessConfig as PreprocessConfig | undefined + ), + preprocessModelLabel: (value, pseudonym) => + preprocessCrossChatLabel(value, pseudonym, options.preprocessConfig as PreprocessConfig | undefined), } return createCrossChatAgentToolAdapters(context) as AgentTool[] diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 631cbeff..fd0b0fc4 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -502,7 +502,9 @@ export { MergeSessionCache, createContactsService, createCrossChatAnalysisService, + preprocessCrossChatLabel, preprocessCrossChatMessages, + preprocessCrossChatSummaries, createPeopleRelationshipsService, createGlobalInsightService, assertValidTopicDayKey, diff --git a/packages/node-runtime/src/services/cross-chat-analysis/index.ts b/packages/node-runtime/src/services/cross-chat-analysis/index.ts index e1d92a3b..c1c21915 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/index.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/index.ts @@ -1,5 +1,5 @@ export { createCrossChatAnalysisService } from './service' -export { preprocessCrossChatMessages } from './preprocess' +export { preprocessCrossChatLabel, preprocessCrossChatMessages, preprocessCrossChatSummaries } from './preprocess' export type { CrossChatAnalysisService, CrossChatAnalysisServiceDeps } from './service' export type { CrossChatEntityResolution, diff --git a/packages/node-runtime/src/services/cross-chat-analysis/preprocess.ts b/packages/node-runtime/src/services/cross-chat-analysis/preprocess.ts index ee1b23aa..37cedd7c 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/preprocess.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/preprocess.ts @@ -1,7 +1,7 @@ -import { getSessionMeta } from '@openchatlab/core' -import type { CrossChatMessageSource } from '@openchatlab/shared-types' +import { getNonSystemMembersForContacts, getSessionMeta } from '@openchatlab/core' +import type { CrossChatMessageSource, CrossChatRecentSessionSummary } from '@openchatlab/shared-types' import type { PreprocessConfig } from '../../ai/preprocessor' -import { preprocessMessages } from '../../ai/preprocessor' +import { desensitizeText, matchesBlacklist, preprocessMessages } from '../../ai/preprocessor' import type { SessionRuntimeAdapter } from '../adapters' /** @@ -35,3 +35,136 @@ export function preprocessCrossChatMessages( ownerPlatformId && message.senderPlatformId === ownerPlatformId ? 'Owner' : `U${message.senderId}@${sessionId}`, })) } + +/** Apply the current privacy policy to persisted summaries before they reach an LLM. */ +export function preprocessCrossChatSummaries( + adapter: SessionRuntimeAdapter, + sessionId: string, + summaries: CrossChatRecentSessionSummary[], + config?: PreprocessConfig +): CrossChatRecentSessionSummary[] { + if (!config) return summaries.map(cloneSummary) + + const participantPseudonyms = config.anonymizeNames + ? buildParticipantPseudonyms( + adapter, + sessionId, + summaries.flatMap((summary) => summary.participants) + ) + : null + + return summaries.flatMap((summary) => { + if (matchesBlacklist(summary.summary, config.blacklistKeywords)) return [] + + let safeSummary = config.desensitize ? desensitizeText(summary.summary, config.desensitizeRules) : summary.summary + if (participantPseudonyms) { + const anonymizedSummary = replaceKnownParticipantNames(safeSummary, participantPseudonyms) + if (anonymizedSummary === null) return [] + safeSummary = anonymizedSummary + } + const participants = summary.participants + .filter((participant) => !matchesBlacklist(participant, config.blacklistKeywords)) + .map((participant) => { + if (participantPseudonyms) return participantPseudonyms.get(participant) ?? `Participant@${sessionId}` + return config.desensitize ? desensitizeText(participant, config.desensitizeRules) : participant + }) + .filter((participant, index, values) => participant.length > 0 && values.indexOf(participant) === index) + + return [{ ...summary, participants, summary: safeSummary }] + }) +} + +/** Apply privacy settings to a model-visible structural label without changing its local source data. */ +export function preprocessCrossChatLabel(value: string, pseudonym: string, config?: PreprocessConfig): string { + if (!config) return value + if (config.anonymizeNames || matchesBlacklist(value, config.blacklistKeywords)) return pseudonym + return config.desensitize ? desensitizeText(value, config.desensitizeRules) : value +} + +function cloneSummary(summary: CrossChatRecentSessionSummary): CrossChatRecentSessionSummary { + return { ...summary, participants: [...summary.participants] } +} + +function replaceKnownParticipantNames(summary: string, pseudonyms: Map): string | null { + const replacements = new Map() + for (const [participantName, pseudonym] of pseudonyms) { + const trimmedName = participantName.trim() + if (participantName) replacements.set(participantName, pseudonym) + if (trimmedName) replacements.set(trimmedName, pseudonym) + } + if (replacements.size === 0) return summary + + const ambiguousLabels = new Set() + const alternatives = [...replacements.keys()] + .sort((left, right) => Array.from(right).length - Array.from(left).length) + .map((participantName) => { + const escapedName = escapeRegExp(participantName) + if (/^[\p{Script=Latin}\p{N}_]+$/u.test(participantName)) { + return `(? replacements.get(participantName) ?? participantName) + // A one-character non-Latin name cannot be distinguished safely inside an unsegmented word. Dropping the cached + // summary preserves both privacy and meaning; the recent-session tool can still answer from preprocessed messages. + return [...ambiguousLabels].some((participantName) => anonymized.includes(participantName)) ? null : anonymized +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function buildParticipantPseudonyms( + adapter: SessionRuntimeAdapter, + sessionId: string, + participantNames: string[] +): Map { + const pseudonyms = new Map() + const fallbackPseudonyms = new Map() + let ownerPlatformId: string | null = null + let members: ReturnType = [] + + try { + const db = adapter.openReadonly(sessionId) + if (db) { + ownerPlatformId = getSessionMeta(db)?.ownerId?.trim() || null + members = getNonSystemMembersForContacts(db) + } + } catch { + // Unknown names still receive deterministic session-local pseudonyms below. + } + + for (const participantName of participantNames) { + if (pseudonyms.has(participantName)) continue + const normalizedName = participantName.trim().toLocaleLowerCase() + const matches = members.filter((member) => + [member.name, member.platformId, ...member.aliases].some( + (candidate) => candidate.trim().toLocaleLowerCase() === normalizedName + ) + ) + const uniqueMatches = [...new Map(matches.map((member) => [member.id, member])).values()] + if (uniqueMatches.length === 1) { + const member = uniqueMatches[0] + pseudonyms.set( + participantName, + ownerPlatformId && member.platformId === ownerPlatformId ? 'Owner' : `U${member.id}@${sessionId}` + ) + continue + } + + let fallback = fallbackPseudonyms.get(normalizedName) + if (!fallback) { + fallback = `Participant${fallbackPseudonyms.size + 1}@${sessionId}` + fallbackPseudonyms.set(normalizedName, fallback) + } + pseudonyms.set(participantName, fallback) + } + return pseudonyms +} diff --git a/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts b/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts index f7c1d65f..212a0d12 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.test.ts @@ -16,7 +16,7 @@ import { openBetterSqliteDatabase } from '../../better-sqlite3-adapter' import type { ContactsService } from '../contacts' import type { SessionRuntimeAdapter } from '../adapters' import { createCrossChatAnalysisService } from './service' -import { preprocessCrossChatMessages } from './preprocess' +import { preprocessCrossChatLabel, preprocessCrossChatMessages, preprocessCrossChatSummaries } from './preprocess' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') @@ -26,7 +26,7 @@ interface SeedSession { type: 'private' | 'group' platform?: string ownerPlatformId?: string - members: Array<{ id: number; platformId: string; name: string }> + members: Array<{ id: number; platformId: string; name: string | null }> messages: Array<{ id: number; senderId: number; ts: number; content: string; replyToMessageId?: string }> } @@ -585,6 +585,33 @@ test('maps global insight cache states without waiting for background computatio } }) +test('uses the private session name when the contact member only has a platform id', async () => { + const { env, contactsService } = createFixture() + const day = 1_780_000_000 + env.seed({ + id: 'rank-private-display-name', + name: '小红', + type: 'private', + platform: 'wechat', + ownerPlatformId: 'owner', + members: [ + { id: 1, platformId: 'owner', name: '我' }, + { id: 2, platformId: 'wxid_adczii0mtmoq22', name: null }, + ], + messages: [{ id: 1, senderId: 2, ts: day, content: 'hello' }], + }) + + try { + const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService }) + const result = await service.rankPrivateContacts({ startTs: day - 1, endTs: day + 1, limit: 10 }) + + const item = result.items.find((candidate) => candidate.contactKey === 'wechat:wxid_adczii0mtmoq22') + assert.equal(item?.displayName, '小红') + } finally { + env.cleanup() + } +}) + test('contact lookup uses the all-history snapshot for typed names and candidate details', () => { const { env } = createFixture() const pagePresets: Array = [] @@ -2014,3 +2041,112 @@ test('cross-chat anonymization namespaces local member ids by source session', ( env.cleanup() } }) + +test('cross-chat summary preprocessing applies current blacklist, desensitization, and anonymization', () => { + const { env } = createFixture() + try { + const summaries = [ + { + segmentId: 1, + startTs: 1, + endTs: 2, + messageCount: 2, + participants: ['Alice'], + summary: '秘密项目的联系电话是 13812345678', + }, + { + segmentId: 2, + startTs: 3, + endTs: 4, + messageCount: 2, + participants: ['Alice'], + summary: 'Alice 的联系电话是 13812345678', + }, + ] + const processed = preprocessCrossChatSummaries(env.adapter, 'private-alice', summaries, { + dataCleaning: false, + mergeConsecutive: false, + blacklistKeywords: ['秘密项目'], + denoise: false, + desensitize: true, + desensitizeRules: [ + { + id: 'phone', + label: 'Phone', + pattern: '1\\d{10}', + replacement: '[PHONE]', + enabled: true, + builtin: false, + locales: [], + }, + ], + anonymizeNames: true, + }) + + assert.equal(processed.length, 1) + assert.equal(processed[0].summary, 'U10@private-alice 的联系电话是 [PHONE]') + assert.deepEqual(processed[0].participants, ['U10@private-alice']) + assert.equal(JSON.stringify(processed).includes('秘密项目'), false) + assert.equal(JSON.stringify(processed).includes('13812345678'), false) + assert.equal(JSON.stringify(processed).includes('Alice'), false) + } finally { + env.cleanup() + } +}) + +test('cross-chat summary anonymization respects word boundaries and drops ambiguous short-name text', () => { + const { env } = createFixture() + try { + const processed = preprocessCrossChatSummaries( + env.adapter, + 'private-alice', + [ + { + segmentId: 1, + startTs: 1, + endTs: 2, + messageCount: 2, + participants: ['Me'], + summary: 'Meeting notes say Me approved it.', + }, + { + segmentId: 2, + startTs: 3, + endTs: 4, + messageCount: 2, + participants: ['我'], + summary: '我们明天继续讨论。', + }, + ], + { + dataCleaning: false, + mergeConsecutive: false, + blacklistKeywords: [], + denoise: false, + desensitize: false, + desensitizeRules: [], + anonymizeNames: true, + } + ) + + assert.equal(processed.length, 1) + assert.equal(processed[0].summary, 'Meeting notes say U1@private-alice approved it.') + assert.deepEqual(processed[0].participants, ['U1@private-alice']) + } finally { + env.cleanup() + } +}) + +test('cross-chat structural labels use safe pseudonyms when name anonymization is enabled', () => { + const processed = preprocessCrossChatLabel('Alice', 'Contact1', { + dataCleaning: false, + mergeConsecutive: false, + blacklistKeywords: [], + denoise: false, + desensitize: false, + desensitizeRules: [], + anonymizeNames: true, + }) + + assert.equal(processed, 'Contact1') +}) diff --git a/packages/node-runtime/src/services/cross-chat-analysis/service.ts b/packages/node-runtime/src/services/cross-chat-analysis/service.ts index 20e77aa9..4faf90a1 100644 --- a/packages/node-runtime/src/services/cross-chat-analysis/service.ts +++ b/packages/node-runtime/src/services/cross-chat-analysis/service.ts @@ -925,7 +925,8 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { const contactActivity = facts.members.find((member) => member.memberId === contact.id) const accumulator = accumulators.get(contactKey) ?? { contactKey, - displayName: contact.name, + platformId: contact.platformId, + displayName: pickPreferredContactDisplayName(contact.platformId, contact.name, descriptor.sessionName), platform: descriptor.platform, totalMessages: 0, ownerMessages: 0, @@ -935,6 +936,12 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { lastMessageTs: null, sessionIds: [], } + accumulator.displayName = pickPreferredContactDisplayName( + contact.platformId, + accumulator.displayName, + contact.name, + descriptor.sessionName + ) accumulator.totalMessages += facts.totalMessages accumulator.ownerMessages += ownerActivity?.messageCount ?? 0 accumulator.contactMessages += contactActivity?.messageCount ?? 0 @@ -964,19 +971,29 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService { const ranked = [...accumulators.values()].sort((left, right) => comparePrivateContactRankAccumulators(left, right, rankBy) ) - const items: CrossChatPrivateContactRankItem[] = ranked.slice(0, limit).map((item, index) => ({ - rank: index + 1, - contactKey: item.contactKey, - displayName: item.displayName, - platform: item.platform, - totalMessages: item.totalMessages, - ownerMessages: item.ownerMessages, - contactMessages: item.contactMessages, - activeDays: item.activeDayKeys.size, - firstMessageTs: item.firstMessageTs, - lastMessageTs: item.lastMessageTs, - sessionIds: [...item.sessionIds].sort(), - })) + const items: CrossChatPrivateContactRankItem[] = ranked.slice(0, limit).map((item, index) => { + const contactDetail = this.deps.contactsService.getContactDetail(item.contactKey, { + acceptStale: true, + timeRangePreset: 'all', + }) + return { + rank: index + 1, + contactKey: item.contactKey, + displayName: pickPreferredContactDisplayName( + item.platformId, + contactDetail.contact?.displayName, + item.displayName + ), + platform: item.platform, + totalMessages: item.totalMessages, + ownerMessages: item.ownerMessages, + contactMessages: item.contactMessages, + activeDays: item.activeDayKeys.size, + firstMessageTs: item.firstMessageTs, + lastMessageTs: item.lastMessageTs, + sessionIds: [...item.sessionIds].sort(), + } + }) const currentTs = Math.floor(this.now() / 1000) return { algorithmVersion: PRIVATE_CONTACTS_RANKING_ALGORITHM_VERSION, @@ -1369,6 +1386,15 @@ function normalizeContactName(value: string): string { return value.trim().toLocaleLowerCase() } +function pickPreferredContactDisplayName(platformId: string, ...candidates: Array): string { + const normalizedPlatformId = platformId.trim() + for (const candidate of candidates) { + const value = candidate?.trim() + if (value && value !== normalizedPlatformId) return value + } + return candidates.find((candidate) => candidate?.trim())?.trim() ?? normalizedPlatformId +} + function normalizeRecentDays(value: number | undefined): number | undefined { if (value === undefined) return undefined if (!Number.isFinite(value) || value <= 0) throw new Error('recentDays must be a positive number') @@ -1382,6 +1408,7 @@ interface SearchCandidate { interface PrivateContactRankAccumulator { contactKey: string + platformId: string displayName: string platform: CrossChatPrivateContactRankItem['platform'] totalMessages: number diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index dc41bcd9..bb16ec6b 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -54,7 +54,12 @@ export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts' export type { ContactsComputeRunner, ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts' // Cross-chat AI analysis service -export { createCrossChatAnalysisService, preprocessCrossChatMessages } from './cross-chat-analysis' +export { + createCrossChatAnalysisService, + preprocessCrossChatLabel, + preprocessCrossChatMessages, + preprocessCrossChatSummaries, +} from './cross-chat-analysis' export type { CrossChatAnalysisService, CrossChatAnalysisServiceDeps, diff --git a/packages/tools/src/agent-adapter.test.ts b/packages/tools/src/agent-adapter.test.ts index 6a7d7338..288f3661 100644 --- a/packages/tools/src/agent-adapter.test.ts +++ b/packages/tools/src/agent-adapter.test.ts @@ -60,6 +60,8 @@ test('the shared Desktop and CLI Web adapter exposes and executes all ten cross- analysisService, maxToolResultTokens: 16_000, preprocessMessagesBySession: async (_sessionId, messages) => messages, + preprocessSummariesBySession: async (_sessionId, summaries) => summaries, + preprocessModelLabel: (value) => value, }) assert.equal(tools.length, 10) diff --git a/packages/tools/src/definitions/cross-chat-tools.test.ts b/packages/tools/src/definitions/cross-chat-tools.test.ts index 8b70065c..feb6674f 100644 --- a/packages/tools/src/definitions/cross-chat-tools.test.ts +++ b/packages/tools/src/definitions/cross-chat-tools.test.ts @@ -220,7 +220,10 @@ function groupSessionsRankingResult(): CrossChatGroupSessionsRankingResult { } } -function globalActivitySummaryResult(dataState: CrossChatGlobalActivitySummaryResult['dataState'] = 'fresh') { +function globalActivitySummaryResult( + dataState: CrossChatGlobalActivitySummaryResult['dataState'] = 'fresh', + dailyActivityCount = 1 +) { return { mode: 'year', dataState, @@ -237,7 +240,10 @@ function globalActivitySummaryResult(dataState: CrossChatGlobalActivitySummaryRe }, monthlyActivity: [{ month: '2026-01', messageCount: 100 }], monthlyDirectContacts: [{ month: '2026-01', contactCount: 5 }], - dailyActivity: [{ date: '2026-01-01', messageCount: 10 }], + dailyActivity: Array.from({ length: dailyActivityCount }, (_, index) => ({ + date: new Date(Date.UTC(2024, 0, index + 1)).toISOString().slice(0, 10), + messageCount: index + 1, + })), messageTypes: [{ type: 0, count: 100 }], textLength: { textMessageCount: 100, median: 5, p90: 20, buckets: [] }, coverage: { @@ -493,6 +499,8 @@ function createContext(overrides: Partial = {}): C analysisService: service, preprocessMessagesBySession: (_sessionId, messages) => messages.map((message) => ({ ...message, senderName: 'U1', content: '[redacted]' })), + preprocessSummariesBySession: (_sessionId, summaries) => summaries, + preprocessModelLabel: (value) => value, } } @@ -609,6 +617,82 @@ describe('cross-chat agent registry', () => { }) }) + it('keeps a multi-group overview within the injected tool token budget', async () => { + const overview: CrossChatOverviewResult = { + appliedRange: { + startTs: 100, + endTs: 200, + dataEarliestMessageTs: 100, + dataLatestMessageTs: 200, + currentTs: 300, + }, + items: Array.from({ length: 24 }, (_, sessionIndex) => ({ + sessionId: `group-${sessionIndex}-abcdefghijklmnop`, + sessionName: `Project collaboration group ${sessionIndex}`, + sessionType: ChatType.GROUP, + platform: 'test', + label: `Project collaboration group ${sessionIndex}`, + memberActivities: [], + totalMessages: 10_000 + sessionIndex, + activeDays: 200, + activeMembers: 80, + firstMessageTs: 100, + lastMessageTs: 200, + ownerStatus: 'resolved', + ownerMessages: 1_000, + ownerActiveDays: 150, + topMembers: Array.from({ length: 5 }, (_, memberIndex) => ({ + memberId: memberIndex + 1, + platformId: `wxid-${sessionIndex}-${memberIndex}-abcdefghijklmnop`, + memberName: `Member ${memberIndex} from project group ${sessionIndex}`, + messageCount: 2_000 - memberIndex, + activeDays: 120, + firstMessageTs: 100, + lastMessageTs: 200, + })), + })), + coverage: { + candidateSessions: 24, + analyzedSessions: 24, + excludedSessions: 0, + missingOwnerSessions: 0, + unresolvedOwnerSessions: 0, + failedSessions: 0, + failedSessionIds: [], + complete: true, + truncated: false, + truncatedReasons: [], + }, + } + const context = createContext({ getOverview: async () => overview }) + let tokenizerCalls = 0 + const countTokens = (text: string) => { + tokenizerCalls++ + return Math.ceil(Array.from(text).length / 4) + } + context.maxToolResultTokens = 4_096 + context.countTokens = countTokens + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_cross_chat_overview') + assert.ok(tool) + + const result = await tool.handler( + { scopes: overview.items.map((item) => ({ sessionId: item.sessionId })) }, + context + ) + const content = JSON.parse(result.content) + + assert.ok(tokenizerCalls > 0) + assert.ok(countTokens(result.content) <= context.maxToolResultTokens) + assert.equal(content.selection.sessionItemsTotal, 24) + assert.equal(content.selection.sessionItemsReturned, 24) + assert.equal(content.selection.topMembersTotal, 120) + assert.ok(content.selection.topMembersReturned < 120) + assert.equal(content.selection.toolResultTruncated, true) + assert.ok(content.selection.truncatedReasons.includes('top_members_budget')) + assert.equal((result.data as CrossChatOverviewResult).items.length, 24) + assert.equal((result.data as CrossChatOverviewResult).items[0]?.topMembers.length, 5) + }) + it('requires an explicit group-ranking mode and forwards the exact range', async () => { let captured: Parameters[0] | undefined const context = createContext({ @@ -629,6 +713,97 @@ describe('cross-chat agent registry', () => { await assert.rejects(() => tool.handler({ mode: 'unknown' }, context), /mode must be/) }) + it('preprocesses model-visible names in rankings and overview while retaining local details', async () => { + const context = createContext({ + getOverview: async () => ({ + appliedRange: { + startTs: null, + endTs: null, + dataEarliestMessageTs: 100, + dataLatestMessageTs: 200, + currentTs: 300, + }, + items: [ + { + sessionId: 'group-a', + sessionName: 'Secret Group', + sessionType: ChatType.GROUP, + platform: 'test', + lastMessageTs: 200, + label: 'Selected Alice', + memberIds: [2], + memberNames: ['Alice'], + memberActivities: [ + { + memberId: 2, + platformId: 'user-2', + memberName: 'Alice', + messageCount: 20, + activeDays: 3, + firstMessageTs: 100, + lastMessageTs: 200, + }, + ], + totalMessages: 20, + activeDays: 3, + activeMembers: 1, + firstMessageTs: 100, + ownerStatus: 'resolved', + ownerMessages: 5, + ownerActiveDays: 2, + topMembers: [ + { + memberId: 3, + platformId: 'user-3', + memberName: 'Bob', + messageCount: 30, + activeDays: 4, + firstMessageTs: 100, + lastMessageTs: 200, + }, + ], + }, + ], + coverage: { + candidateSessions: 1, + analyzedSessions: 1, + excludedSessions: 0, + missingOwnerSessions: 0, + unresolvedOwnerSessions: 0, + failedSessions: 0, + failedSessionIds: [], + complete: true, + truncated: false, + truncatedReasons: [], + }, + }), + }) + context.preprocessModelLabel = (_value, pseudonym) => pseudonym + + const privateTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'rank_private_contacts') + const groupTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'rank_group_sessions') + const overviewTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_cross_chat_overview') + assert.ok(privateTool) + assert.ok(groupTool) + assert.ok(overviewTool) + + const privateResult = await privateTool.handler({}, context) + const groupResult = await groupTool.handler({ mode: 'owner_activity' }, context) + const overviewResult = await overviewTool.handler({ scopes: [{ sessionId: 'group-a' }] }, context) + const privateContent = JSON.parse(privateResult.content) + const groupContent = JSON.parse(groupResult.content) + const overviewContent = JSON.parse(overviewResult.content) + + assert.equal(privateContent.items[0].displayName, 'Contact1') + assert.equal(groupContent.items[0].sessionName, 'Group1') + assert.equal(overviewContent.items[0].sessionName, 'Session1') + assert.equal(overviewContent.items[0].label, 'Session1') + assert.equal(overviewContent.items[0].memberActivities[0].memberName, 'U2@group-a') + assert.equal(overviewContent.items[0].topMembers[0].memberName, 'U3@group-a') + assert.equal((privateResult.data as CrossChatPrivateContactsRankingResult).items[0].displayName, 'Alice') + assert.equal((groupResult.data as CrossChatGroupSessionsRankingResult).items[0].sessionName, 'Group A') + }) + it('reads the cached global activity summary without exposing cache signatures', async () => { let captured: Parameters[0] | undefined const context = createContext({ @@ -651,6 +826,27 @@ describe('cross-chat agent registry', () => { assert.deepEqual(result.data, globalActivitySummaryResult('stale')) }) + it('keeps a full-year activity summary within the injected tool token budget', async () => { + const fullSummary = globalActivitySummaryResult('fresh', 366) + const context = createContext({ getGlobalActivitySummary: () => fullSummary }) + const countTokens = (text: string) => Math.ceil(Array.from(text).length / 4) + context.maxToolResultTokens = 1_000 + context.countTokens = countTokens + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_global_activity_summary') + assert.ok(tool) + + const result = await tool.handler({ mode: 'year', year: 2024 }, context) + const content = JSON.parse(result.content) + + assert.ok(countTokens(result.content) <= context.maxToolResultTokens) + assert.equal(content.metrics.sentMessageCount, 100) + assert.equal(content.selection.dailyActivityTotal, 366) + assert.ok(content.selection.dailyActivityReturned < 366) + assert.equal(content.selection.toolResultTruncated, true) + assert.ok(content.selection.truncatedReasons.includes('daily_activity_budget')) + assert.equal((result.data as CrossChatGlobalActivitySummaryResult).summary.dailyActivity.length, 366) + }) + it('reads one resolved session through the bounded recap path and preserves evidence', async () => { let capturedSessionId: string | undefined const context = createContext({ @@ -674,6 +870,64 @@ describe('cross-chat agent registry', () => { assert.equal(result.data?.crossChatEvidence.sources[0].snippet, '[redacted]') }) + it('uses only privacy-processed summaries in recent session tool content', async () => { + const context = createContext({ + readRecentSession: () => { + const result = recentSessionResult() + return { + ...result, + summaries: [ + ...result.summaries, + { + ...result.summaries[0], + segmentId: 4, + participants: ['Bob'], + summary: 'Filtered summary', + }, + ], + } + }, + }) + context.preprocessSummariesBySession = (sessionId, summaries) => { + assert.equal(sessionId, 'session-a') + return summaries.slice(0, 1).map((summary) => ({ + ...summary, + participants: ['U1@session-a'], + summary: summary.summary.replace('Recent topic summary', '[safe summary]'), + })) + } + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'read_recent_session') + assert.ok(tool) + + const result = await tool.handler({ session_id: 'session-a' }, context) + const content = JSON.parse(result.content) + + assert.equal(content.selection.returnedSummaries, 1) + assert.equal(content.summaries[0].summary, '[safe summary]') + assert.deepEqual(content.summaries[0].participants, ['U1@session-a']) + assert.equal(result.content.includes('Recent topic summary'), false) + assert.equal(result.content.includes('Filtered summary'), false) + assert.equal(result.content.includes('Alice'), false) + assert.equal(result.content.includes('Bob'), false) + }) + + it('preprocesses recent-session labels only in model-visible content', async () => { + const context = createContext() + context.preprocessModelLabel = (_value, pseudonym) => pseudonym + const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'read_recent_session') + assert.ok(tool) + + const result = await tool.handler({ session_id: 'session-a' }, context) + const content = JSON.parse(result.content) + + 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') + }) + it('keeps latest private-chat recaps outside the default thirty-day window', () => { const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally') assert.ok(tool) diff --git a/packages/tools/src/definitions/cross-chat-tools.ts b/packages/tools/src/definitions/cross-chat-tools.ts index 4bae388e..57937d7a 100644 --- a/packages/tools/src/definitions/cross-chat-tools.ts +++ b/packages/tools/src/definitions/cross-chat-tools.ts @@ -7,6 +7,8 @@ import type { CrossChatGroupSessionsRankingResult, CrossChatGlobalActivitySummaryResult, CrossChatMessageSource, + CrossChatOverviewItem, + CrossChatOverviewResult, CrossChatParticipantRef, CrossChatPrivateContactsRankingResult, CrossChatResolvedContactSession, @@ -336,18 +338,28 @@ async function readRecentSessionHandler( ): Promise { const result = context.analysisService.readRecentSession(requireString(params.session_id, 'session_id')) const safeMessages = await preprocessBySession(context, result.messages) + const safeSummaries = await context.preprocessSummariesBySession(result.source.sessionId, result.summaries) + const sessionPseudonym = 'Session1' + const modelSource = { + ...result.source, + sessionName: context.preprocessModelLabel(result.source.sessionName, sessionPseudonym), + } + const toRecentModelMessage = (message: CrossChatMessageSource) => ({ + ...toModelMessage(message), + sessionName: context.preprocessModelLabel(message.sessionName, sessionPseudonym), + }) const buildModelData = (messages: CrossChatMessageSource[], budgetTruncated: boolean) => ({ - source: result.source, + source: modelSource, selection: { strategy: 'latest_session_slice', totalMessages: result.coverage.totalMessages, returnedMessages: messages.length, - returnedSummaries: result.summaries.length, + returnedSummaries: safeSummaries.length, hasEarlierMessages: result.coverage.hasEarlierMessages, toolResultTruncated: budgetTruncated, }, - summaries: result.summaries, - messages: messages.map(toModelMessage), + summaries: safeSummaries, + messages: messages.map(toRecentModelMessage), }) const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, { countTokens: context.countTokens, @@ -370,6 +382,8 @@ async function readRecentSessionHandler( content: JSON.stringify(modelData), data: { ...modelData, + source: result.source, + messages: limited.messages.map(toModelMessage), crossChatEvidence: evidence, }, } @@ -426,7 +440,29 @@ async function overviewHandler( items: result.items.map(({ memberNames: _memberNames, ...item }) => item), coverage: result.coverage, } - return { content: JSON.stringify(data), data } + const fullModelData: OverviewModelData = { + ...data, + items: data.items.map((item, index) => { + const sessionPseudonym = `Session${index + 1}` + const preprocessMember = (member: (typeof item.memberActivities)[number]) => ({ + ...member, + memberName: context.preprocessModelLabel(member.memberName, `U${member.memberId}@${item.sessionId}`), + }) + return { + ...item, + sessionName: context.preprocessModelLabel(item.sessionName, sessionPseudonym), + label: context.preprocessModelLabel(item.label, sessionPseudonym), + memberActivities: item.memberActivities.map(preprocessMember), + topMembers: item.topMembers.map(preprocessMember), + } + }), + } + const modelData = limitOverviewToBudget( + fullModelData, + context.maxToolResultTokens, + context.countTokens ?? estimatePayloadTokens + ) + return { content: JSON.stringify(modelData), data } } async function rankPrivateContactsHandler( @@ -452,7 +488,14 @@ async function rankPrivateContactsHandler( }), } ) - return { content: JSON.stringify(result), data: result } + const modelData = { + ...result, + items: result.items.map((item) => ({ + ...item, + displayName: context.preprocessModelLabel(item.displayName, `Contact${item.rank}`), + })), + } + return { content: JSON.stringify(modelData), data: result } } async function rankGroupSessionsHandler( @@ -482,7 +525,14 @@ async function rankGroupSessionsHandler( }), } ) - return { content: JSON.stringify(result), data: result } + const modelData = { + ...result, + items: result.items.map((item) => ({ + ...item, + sessionName: context.preprocessModelLabel(item.sessionName, `Group${item.rank}`), + })), + } + return { content: JSON.stringify(modelData), data: result } } async function globalActivitySummaryHandler( @@ -495,8 +545,17 @@ async function globalActivitySummaryHandler( mode, year: parseOptionalNumber(params.year), }) + const modelData = limitGlobalActivitySummaryToBudget( + buildGlobalActivityModelData(result), + context.maxToolResultTokens, + context.countTokens ?? estimatePayloadTokens + ) + return { content: JSON.stringify(modelData), data: result } +} + +function buildGlobalActivityModelData(result: CrossChatGlobalActivitySummaryResult) { const summary = result.summary - const modelData = { + return { mode: result.mode, dataState: result.dataState, range: summary.range, @@ -521,8 +580,14 @@ async function globalActivitySummaryHandler( processedSessions: summary.task.processedSessions, totalSessions: summary.task.totalSessions, }, + selection: { + dailyActivityTotal: summary.dailyActivity.length, + dailyActivityReturned: summary.dailyActivity.length, + dailyActivityFormat: 'objects', + toolResultTruncated: false, + truncatedReasons: [] as string[], + }, } - return { content: JSON.stringify(modelData), data: result } } async function inspectContactSessionsHandler( @@ -768,6 +833,266 @@ function parseParticipants(value: unknown): CrossChatParticipantRef[] { }) } +type GlobalActivityModelData = ReturnType + +interface OverviewModelData { + appliedRange: CrossChatOverviewResult['appliedRange'] + items: Array> + coverage: CrossChatOverviewResult['coverage'] +} + +type OverviewItemFormat = 'compact' | 'core' | 'identity' | 'omitted' + +function limitOverviewToBudget( + fullData: OverviewModelData, + maxToolResultTokens: number | undefined, + countTokens: (text: string) => number +): unknown { + if (!maxToolResultTokens || maxToolResultTokens <= 0) return fullData + if (countTokens(JSON.stringify(fullData)) <= maxToolResultTokens) return fullData + + const compactItems = fullData.items.map((item) => ({ + ...item, + sessionName: truncateModelLabel(item.sessionName), + label: truncateModelLabel(item.label), + memberActivities: item.memberActivities.map((member) => ({ + memberId: member.memberId, + memberName: truncateModelLabel(member.memberName), + messageCount: member.messageCount, + activeDays: member.activeDays, + firstMessageTs: member.firstMessageTs, + lastMessageTs: member.lastMessageTs, + })), + topMembers: item.topMembers.map((member) => ({ + memberName: truncateModelLabel(member.memberName), + messageCount: member.messageCount, + activeDays: member.activeDays, + lastMessageTs: member.lastMessageTs, + })), + })) + const buildCandidate = (items: Array>, itemFormat: OverviewItemFormat) => ({ + appliedRange: fullData.appliedRange, + items, + coverage: fullData.coverage, + selection: buildOverviewSelection(fullData.items, items, itemFormat), + }) + const fits = (candidate: unknown) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens + + // 优先保留所有会话的核心统计,先均匀收缩每个会话的成员明细,避免只留下输入顺序靠前的会话。 + const maxTopMembers = compactItems.reduce((max, item) => Math.max(max, item.topMembers.length), 0) + const topMemberCandidate = findLargestOverviewDetailLimit(maxTopMembers, (limit) => + buildCandidate( + compactItems.map((item) => ({ ...item, topMembers: item.topMembers.slice(0, limit) })), + 'compact' + ) + ) + if (topMemberCandidate && fits(topMemberCandidate)) return topMemberCandidate + + const maxMemberActivities = compactItems.reduce((max, item) => Math.max(max, item.memberActivities.length), 0) + const memberActivityCandidate = findLargestOverviewDetailLimit(maxMemberActivities, (limit) => + buildCandidate( + compactItems.map((item) => ({ + ...item, + memberActivities: item.memberActivities.slice(0, limit), + topMembers: [], + })), + 'compact' + ) + ) + if (memberActivityCandidate && fits(memberActivityCandidate)) return memberActivityCandidate + + const coreItems = compactItems.map((item) => ({ + sessionId: item.sessionId, + sessionName: item.sessionName, + sessionType: item.sessionType, + platform: item.platform, + label: item.label, + totalMessages: item.totalMessages, + activeDays: item.activeDays, + activeMembers: item.activeMembers, + firstMessageTs: item.firstMessageTs, + lastMessageTs: item.lastMessageTs, + ownerStatus: item.ownerStatus, + ownerMessages: item.ownerMessages, + ownerActiveDays: item.ownerActiveDays, + })) + const coreCandidate = buildCandidate(coreItems, 'core') + if (fits(coreCandidate)) return coreCandidate + + const identityItems = compactItems.map((item) => ({ + sessionId: item.sessionId, + sessionName: item.sessionName, + sessionType: item.sessionType, + label: item.label, + totalMessages: item.totalMessages, + activeDays: item.activeDays, + lastMessageTs: item.lastMessageTs, + })) + const identityCandidate = buildCandidate(identityItems, 'identity') + if (fits(identityCandidate)) return identityCandidate + + const returnedItems = findLargestOverviewItemSlice(identityItems, (items) => buildCandidate(items, 'identity')) + if (returnedItems) return returnedItems + + const omitted = buildCandidate([], 'omitted') + const candidates: unknown[] = [ + omitted, + { + selection: buildOverviewSelection(fullData.items, [], 'omitted'), + }, + { toolResultTruncated: true, truncatedReasons: ['tool_result_budget'] }, + {}, + ] + return candidates.find(fits) ?? {} + + function findLargestOverviewDetailLimit( + maximum: number, + build: (limit: number) => ReturnType + ): ReturnType | null { + if (!fits(build(0))) return null + let low = 0 + let high = maximum + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (fits(build(middle))) low = middle + else high = middle - 1 + } + return build(low) + } + + function findLargestOverviewItemSlice( + items: Array>, + build: (items: Array>) => ReturnType + ): ReturnType | null { + if (!fits(build([]))) return null + let low = 0 + let high = items.length + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (fits(build(items.slice(0, middle)))) low = middle + else high = middle - 1 + } + return build(items.slice(0, low)) + } +} + +function buildOverviewSelection( + fullItems: OverviewModelData['items'], + returnedItems: Array>, + itemFormat: OverviewItemFormat +) { + const memberActivitiesTotal = fullItems.reduce((sum, item) => sum + item.memberActivities.length, 0) + const topMembersTotal = fullItems.reduce((sum, item) => sum + item.topMembers.length, 0) + const memberActivitiesReturned = returnedItems.reduce( + (sum, item) => sum + (Array.isArray(item.memberActivities) ? item.memberActivities.length : 0), + 0 + ) + const topMembersReturned = returnedItems.reduce( + (sum, item) => sum + (Array.isArray(item.topMembers) ? item.topMembers.length : 0), + 0 + ) + const truncatedReasons = [ + ...(memberActivitiesReturned < memberActivitiesTotal ? ['member_activities_budget'] : []), + ...(topMembersReturned < topMembersTotal ? ['top_members_budget'] : []), + ...(returnedItems.length < fullItems.length ? ['session_items_budget'] : []), + 'item_fields_budget', + 'tool_result_budget', + ] + return { + sessionItemsTotal: fullItems.length, + sessionItemsReturned: returnedItems.length, + memberActivitiesTotal, + memberActivitiesReturned, + topMembersTotal, + topMembersReturned, + itemFormat, + toolResultTruncated: true, + truncatedReasons, + } +} + +function limitGlobalActivitySummaryToBudget( + fullData: GlobalActivityModelData, + maxToolResultTokens: number | undefined, + countTokens: (text: string) => number +): unknown { + if (!maxToolResultTokens || maxToolResultTokens <= 0) return fullData + if (countTokens(JSON.stringify(fullData)) <= maxToolResultTokens) return fullData + + const totalDailyPoints = fullData.dailyActivity.length + const buildCompactedDailyData = (returned: number) => ({ + ...fullData, + dailyActivity: sampleEvenly(fullData.dailyActivity, returned).map( + (point) => [point.date, point.messageCount] as const + ), + selection: { + dailyActivityTotal: totalDailyPoints, + dailyActivityReturned: returned, + dailyActivityFormat: 'date_message_count_tuples', + toolResultTruncated: returned < totalDailyPoints, + truncatedReasons: returned < totalDailyPoints ? ['daily_activity_budget'] : [], + }, + }) + + let low = 0 + let high = totalDailyPoints + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (countTokens(JSON.stringify(buildCompactedDailyData(middle))) <= maxToolResultTokens) low = middle + else high = middle - 1 + } + const compacted = buildCompactedDailyData(low) + if (countTokens(JSON.stringify(compacted)) <= maxToolResultTokens) return compacted + + const truncatedReasons = [...(totalDailyPoints > 0 ? ['daily_activity_budget'] : []), 'tool_result_budget'] + const selection = { + dailyActivityTotal: totalDailyPoints, + dailyActivityReturned: 0, + dailyActivityFormat: 'omitted', + toolResultTruncated: true, + truncatedReasons, + } + const candidates: unknown[] = [ + { + mode: fullData.mode, + dataState: fullData.dataState, + range: fullData.range, + metrics: fullData.metrics, + monthlyActivity: fullData.monthlyActivity, + monthlyDirectContacts: fullData.monthlyDirectContacts, + messageTypes: fullData.messageTypes, + textLength: fullData.textLength, + coverage: fullData.coverage, + cache: fullData.cache, + selection, + }, + { + mode: fullData.mode, + dataState: fullData.dataState, + range: fullData.range, + metrics: fullData.metrics, + coverage: fullData.coverage, + cache: fullData.cache, + selection, + }, + { mode: fullData.mode, dataState: fullData.dataState, metrics: fullData.metrics, selection }, + { dataState: fullData.dataState, selection }, + { toolResultTruncated: true, truncatedReasons: ['tool_result_budget'] }, + {}, + ] + return candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? {} +} + +function sampleEvenly(items: T[], limit: number): T[] { + if (limit <= 0 || items.length === 0) return [] + if (limit >= items.length) return [...items] + if (limit === 1) return [items[items.length - 1]!] + return Array.from({ length: limit }, (_, index) => { + const sourceIndex = Math.round((index * (items.length - 1)) / (limit - 1)) + return items[sourceIndex]! + }) +} + function limitContactPageSizeToBudget( requestedPageSize: number | undefined, maxToolResultTokens: number | undefined diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index eb6512b3..50f092b5 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -24,6 +24,7 @@ import type { CrossChatOverviewRequest, CrossChatOverviewResult, CrossChatRecentSessionResult, + CrossChatRecentSessionSummary, CrossChatSearchRequest, CrossChatSearchResult, CrossChatSharedInteractionsRequest, @@ -404,6 +405,11 @@ export interface CrossChatToolExecutionContext { sessionId: string, messages: CrossChatMessageSource[] ) => CrossChatMessageSource[] | Promise + preprocessSummariesBySession: ( + sessionId: string, + summaries: CrossChatRecentSessionSummary[] + ) => CrossChatRecentSessionSummary[] | Promise + preprocessModelLabel: (value: string, pseudonym: string) => string } // ==================== Raw Message ====================