mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-25 22:10:20 +08:00
1955 lines
64 KiB
TypeScript
1955 lines
64 KiB
TypeScript
import { accessSync, constants, existsSync, mkdirSync } from 'fs'
|
|
import { writeFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { z } from 'zod'
|
|
import { analyticsService } from '../analyticsService'
|
|
import { chatService, type ChatSession, type ContactInfo, type Message } from '../chatService'
|
|
import { ConfigService } from '../config'
|
|
import { exportService, type ExportOptions as ExportServiceOptions } from '../exportService'
|
|
import { imageDecryptService } from '../imageDecryptService'
|
|
import { videoService } from '../videoService'
|
|
import { McpToolError } from './result'
|
|
import {
|
|
MCP_CONTACT_KINDS,
|
|
MCP_MESSAGE_KINDS,
|
|
type McpContactItem,
|
|
type McpContactKind,
|
|
type McpContactsPayload,
|
|
type McpCursor,
|
|
type McpExportChatPayload,
|
|
type McpExportDateRange,
|
|
type McpExportFormat,
|
|
type McpExportMediaOptions,
|
|
type McpExportMissingField,
|
|
type McpGlobalStatisticsPayload,
|
|
type McpContactRankingItem,
|
|
type McpContactRankingsPayload,
|
|
type McpActivityDistributionPayload,
|
|
type McpMessageItem,
|
|
type McpMessageKind,
|
|
type McpMessageMatchField,
|
|
type McpSearchMatchMode,
|
|
type McpMessagesPayload,
|
|
type McpStreamPartialPayloadMap,
|
|
type McpStreamProgressPayload,
|
|
type McpSearchHit,
|
|
type McpSearchMessagesPayload,
|
|
type McpResolveSessionPayload,
|
|
type McpResolvedSessionCandidate,
|
|
type McpSessionContextPayload,
|
|
type McpSessionItem,
|
|
type McpSessionKind,
|
|
type McpSessionRef,
|
|
type McpSessionsPayload
|
|
} from './types'
|
|
|
|
const MAX_LIST_LIMIT = 200
|
|
const MAX_SEARCH_LIMIT = 100
|
|
const MAX_CONTEXT_LIMIT = 100
|
|
const SEARCH_BATCH_SIZE = 200
|
|
const MAX_SEARCH_SESSIONS = 20
|
|
const MAX_SCAN_PER_SESSION = 1000
|
|
const MAX_SCAN_GLOBAL = 10000
|
|
|
|
const listSessionsArgsSchema = z.object({
|
|
q: z.string().optional(),
|
|
offset: z.number().int().nonnegative().optional(),
|
|
limit: z.number().int().positive().optional(),
|
|
unreadOnly: z.boolean().optional()
|
|
})
|
|
|
|
const resolveSessionArgsSchema = z.object({
|
|
query: z.string().trim().min(1),
|
|
limit: z.number().int().positive().optional()
|
|
})
|
|
|
|
const exportChatArgsSchema = z.object({
|
|
sessionId: z.string().trim().min(1).optional(),
|
|
query: z.string().trim().min(1).optional(),
|
|
format: z.enum(['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']).optional(),
|
|
dateRange: z.object({
|
|
start: z.number().int().positive(),
|
|
end: z.number().int().positive()
|
|
}).optional(),
|
|
mediaOptions: z.object({
|
|
exportAvatars: z.boolean().optional(),
|
|
exportImages: z.boolean().optional(),
|
|
exportVideos: z.boolean().optional(),
|
|
exportEmojis: z.boolean().optional(),
|
|
exportVoices: z.boolean().optional()
|
|
}).optional(),
|
|
outputDir: z.string().trim().min(1).optional(),
|
|
validateOnly: z.boolean().optional()
|
|
}).superRefine((value, ctx) => {
|
|
if (!value.sessionId && !value.query) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['sessionId'],
|
|
message: 'sessionId or query is required'
|
|
})
|
|
}
|
|
})
|
|
|
|
const getMessagesArgsSchema = z.object({
|
|
sessionId: z.string().trim().min(1),
|
|
offset: z.number().int().nonnegative().optional(),
|
|
limit: z.number().int().positive().optional(),
|
|
order: z.enum(['asc', 'desc']).optional(),
|
|
keyword: z.string().optional(),
|
|
startTime: z.number().int().positive().optional(),
|
|
endTime: z.number().int().positive().optional(),
|
|
includeRaw: z.boolean().optional(),
|
|
includeMediaPaths: z.boolean().optional()
|
|
})
|
|
|
|
const listContactsArgsSchema = z.object({
|
|
q: z.string().optional(),
|
|
offset: z.number().int().nonnegative().optional(),
|
|
limit: z.number().int().positive().optional(),
|
|
types: z.array(z.enum(MCP_CONTACT_KINDS)).optional()
|
|
})
|
|
|
|
const searchMessagesArgsSchema = z.object({
|
|
query: z.string().trim().min(1),
|
|
sessionId: z.string().trim().min(1).optional(),
|
|
sessionIds: z.array(z.string().trim().min(1)).max(MAX_SEARCH_SESSIONS).optional(),
|
|
startTime: z.number().int().positive().optional(),
|
|
endTime: z.number().int().positive().optional(),
|
|
kinds: z.array(z.enum(MCP_MESSAGE_KINDS)).optional(),
|
|
direction: z.enum(['in', 'out']).optional(),
|
|
senderUsername: z.string().trim().min(1).optional(),
|
|
matchMode: z.enum(['substring', 'exact']).optional(),
|
|
limit: z.number().int().positive().optional(),
|
|
includeRaw: z.boolean().optional(),
|
|
includeMediaPaths: z.boolean().optional()
|
|
})
|
|
|
|
const analyticsTimeRangeArgsSchema = z.object({
|
|
startTime: z.number().int().positive().optional(),
|
|
endTime: z.number().int().positive().optional()
|
|
})
|
|
|
|
const contactRankingsArgsSchema = analyticsTimeRangeArgsSchema.extend({
|
|
limit: z.number().int().positive().optional()
|
|
})
|
|
|
|
const cursorSchema = z.object({
|
|
sortSeq: z.number().int(),
|
|
createTime: z.number().int().positive(),
|
|
localId: z.number().int()
|
|
})
|
|
|
|
const getSessionContextArgsSchema = z.object({
|
|
sessionId: z.string().trim().min(1),
|
|
mode: z.enum(['latest', 'around']),
|
|
anchorCursor: cursorSchema.optional(),
|
|
beforeLimit: z.number().int().positive().optional(),
|
|
afterLimit: z.number().int().positive().optional(),
|
|
includeRaw: z.boolean().optional(),
|
|
includeMediaPaths: z.boolean().optional()
|
|
}).superRefine((value, ctx) => {
|
|
if (value.mode === 'around' && !value.anchorCursor) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['anchorCursor'],
|
|
message: 'anchorCursor is required when mode=around'
|
|
})
|
|
}
|
|
})
|
|
|
|
type ListSessionsArgs = z.infer<typeof listSessionsArgsSchema>
|
|
type ResolveSessionArgs = z.infer<typeof resolveSessionArgsSchema>
|
|
type ExportChatArgs = z.infer<typeof exportChatArgsSchema>
|
|
type GetMessagesArgs = z.infer<typeof getMessagesArgsSchema>
|
|
type ListContactsArgs = z.infer<typeof listContactsArgsSchema>
|
|
type SearchMessagesArgs = z.infer<typeof searchMessagesArgsSchema>
|
|
type GetSessionContextArgs = z.infer<typeof getSessionContextArgsSchema>
|
|
type ContactWithLastContact = ContactInfo & { lastContactTime?: number }
|
|
type MessageNormalizeOptions = {
|
|
includeMediaPaths: boolean
|
|
includeRaw: boolean
|
|
}
|
|
type McpContactRef = {
|
|
contactId: string
|
|
sessionId: string
|
|
displayName: string
|
|
remark?: string
|
|
nickname?: string
|
|
kind: McpContactKind
|
|
}
|
|
type McpSessionLookupEntry = {
|
|
session: McpSessionRef
|
|
aliases: string[]
|
|
}
|
|
type ScoredSessionCandidate = { entry: McpSessionLookupEntry; score: number }
|
|
type SearchRawHit = {
|
|
session: McpSessionRef
|
|
message: Message
|
|
matchedField: McpMessageMatchField
|
|
excerpt: string
|
|
score: number
|
|
}
|
|
type McpStreamReporter = {
|
|
progress?: (payload: McpStreamProgressPayload) => void | Promise<void>
|
|
partial?: <K extends keyof McpStreamPartialPayloadMap>(toolName: K, payload: McpStreamPartialPayloadMap[K]) => void | Promise<void>
|
|
}
|
|
|
|
const SUPPORTED_EXPORT_FORMATS: McpExportFormat[] = ['chatlab', 'chatlab-jsonl', 'json', 'excel', 'html']
|
|
|
|
function toTimestampMs(value?: number | null): number {
|
|
if (!value || !Number.isFinite(value) || value <= 0) return 0
|
|
return value < 1_000_000_000_000 ? value * 1000 : value
|
|
}
|
|
|
|
function detectSessionKind(sessionId: string): McpSessionKind {
|
|
if (sessionId.includes('@chatroom')) return 'group'
|
|
if (sessionId.startsWith('gh_')) return 'official'
|
|
if (sessionId) return 'friend'
|
|
return 'other'
|
|
}
|
|
|
|
function detectMessageKind(message: Pick<Message, 'localType' | 'rawContent' | 'parsedContent'>): McpMessageKind {
|
|
const localType = Number(message.localType || 0)
|
|
const raw = String(message.rawContent || message.parsedContent || '')
|
|
const xmlTypeMatch = raw.match(/<type>\s*([^<]+)\s*<\/type>/i)
|
|
const appMsgType = xmlTypeMatch?.[1]?.trim()
|
|
|
|
if (localType === 1) return 'text'
|
|
if (localType === 3) return 'image'
|
|
if (localType === 34) return 'voice'
|
|
if (localType === 42) return 'contact_card'
|
|
if (localType === 43) return 'video'
|
|
if (localType === 47) return 'emoji'
|
|
if (localType === 48) return 'location'
|
|
if (localType === 50) return 'voip'
|
|
if (localType === 10000) return 'system'
|
|
if (localType === 244813135921) return 'quote'
|
|
|
|
if (localType === 49 || appMsgType) {
|
|
switch (appMsgType) {
|
|
case '3':
|
|
return 'app_music'
|
|
case '5':
|
|
case '49':
|
|
return 'app_link'
|
|
case '6':
|
|
return 'app_file'
|
|
case '19':
|
|
return 'app_chat_record'
|
|
case '33':
|
|
case '36':
|
|
return 'app_mini_program'
|
|
case '57':
|
|
return 'app_quote'
|
|
case '62':
|
|
return 'app_pat'
|
|
case '87':
|
|
return 'app_announcement'
|
|
case '115':
|
|
return 'app_gift'
|
|
case '2000':
|
|
return 'app_transfer'
|
|
case '2001':
|
|
return 'app_red_packet'
|
|
default:
|
|
return 'app'
|
|
}
|
|
}
|
|
|
|
return 'unknown'
|
|
}
|
|
|
|
function compareMessageCursorAsc(
|
|
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
|
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
|
): number {
|
|
return Number(a.sortSeq || 0) - Number(b.sortSeq || 0)
|
|
|| Number(a.createTime || 0) - Number(b.createTime || 0)
|
|
|| Number(a.localId || 0) - Number(b.localId || 0)
|
|
}
|
|
|
|
function compareMessageCursorDesc(
|
|
a: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
|
b: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>
|
|
): number {
|
|
return compareMessageCursorAsc(b, a)
|
|
}
|
|
|
|
function buildCursor(message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>): McpCursor {
|
|
return {
|
|
sortSeq: Number(message.sortSeq || 0),
|
|
createTime: Number(message.createTime || 0),
|
|
localId: Number(message.localId || 0)
|
|
}
|
|
}
|
|
|
|
function sameCursor(
|
|
message: Pick<Message, 'sortSeq' | 'createTime' | 'localId'>,
|
|
cursor: McpCursor
|
|
): boolean {
|
|
return Number(message.sortSeq || 0) === cursor.sortSeq
|
|
&& Number(message.createTime || 0) === cursor.createTime
|
|
&& Number(message.localId || 0) === cursor.localId
|
|
}
|
|
|
|
function uniqueMessageList(messages: Message[]): Message[] {
|
|
const seen = new Set<string>()
|
|
return messages.filter((message) => {
|
|
const key = `${message.serverId}-${message.localId}-${message.createTime}-${message.sortSeq}`
|
|
if (seen.has(key)) return false
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function normalizeQuery(value?: string): string {
|
|
return String(value || '').trim().toLowerCase()
|
|
}
|
|
|
|
function createExcerpt(source: string, matchedIndex: number, queryLength: number): string {
|
|
if (!source) return ''
|
|
const radius = 48
|
|
const safeIndex = Math.max(0, matchedIndex)
|
|
const start = Math.max(0, safeIndex - radius)
|
|
const end = Math.min(source.length, safeIndex + queryLength + radius)
|
|
const prefix = start > 0 ? '...' : ''
|
|
const suffix = end < source.length ? '...' : ''
|
|
return `${prefix}${source.slice(start, end)}${suffix}`
|
|
}
|
|
|
|
function buildTimeRange(startTime?: number, endTime?: number) {
|
|
const normalizedStartTime = startTime && Number.isFinite(startTime) ? Number(startTime) : undefined
|
|
const normalizedEndTime = endTime && Number.isFinite(endTime) ? Number(endTime) : undefined
|
|
|
|
return {
|
|
startTime: normalizedStartTime,
|
|
startTimeMs: normalizedStartTime ? toTimestampMs(normalizedStartTime) : undefined,
|
|
endTime: normalizedEndTime,
|
|
endTimeMs: normalizedEndTime ? toTimestampMs(normalizedEndTime) : undefined
|
|
}
|
|
}
|
|
|
|
function buildSearchScore(args: {
|
|
matchedField: McpMessageMatchField
|
|
matchIndex: number
|
|
excerptLength: number
|
|
}): number {
|
|
const fieldScore = args.matchedField === 'text' ? 1000 : 700
|
|
const positionScore = Math.max(0, 240 - Math.min(args.matchIndex, 240))
|
|
const excerptPenalty = Math.min(args.excerptLength, 200) / 10
|
|
return Number((fieldScore + positionScore - excerptPenalty).toFixed(2))
|
|
}
|
|
|
|
function findKeywordMatch(
|
|
message: Message,
|
|
query: string,
|
|
matchMode: McpSearchMatchMode = 'substring'
|
|
): { matchedField: McpMessageMatchField; excerpt: string; score: number } | null {
|
|
const exactQuery = String(query || '').trim()
|
|
const normalizedQuery = normalizeQuery(query)
|
|
if (!normalizedQuery || !exactQuery) return null
|
|
|
|
const text = String(message.parsedContent || '')
|
|
const raw = String(message.rawContent || '')
|
|
const textIndex = matchMode === 'exact'
|
|
? text.indexOf(exactQuery)
|
|
: text.toLowerCase().indexOf(normalizedQuery)
|
|
if (textIndex >= 0) {
|
|
const excerpt = createExcerpt(text, textIndex, normalizedQuery.length)
|
|
return {
|
|
matchedField: 'text',
|
|
excerpt,
|
|
score: buildSearchScore({
|
|
matchedField: 'text',
|
|
matchIndex: textIndex,
|
|
excerptLength: excerpt.length
|
|
})
|
|
}
|
|
}
|
|
|
|
const rawIndex = matchMode === 'exact'
|
|
? raw.indexOf(exactQuery)
|
|
: raw.toLowerCase().indexOf(normalizedQuery)
|
|
if (rawIndex >= 0) {
|
|
const excerpt = createExcerpt(raw, rawIndex, normalizedQuery.length)
|
|
return {
|
|
matchedField: 'raw',
|
|
excerpt,
|
|
score: buildSearchScore({
|
|
matchedField: 'raw',
|
|
matchIndex: rawIndex,
|
|
excerptLength: excerpt.length
|
|
})
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function toSessionRef(session: Pick<ChatSession, 'username' | 'displayName'>): McpSessionRef {
|
|
return {
|
|
sessionId: session.username,
|
|
displayName: session.displayName || session.username,
|
|
kind: detectSessionKind(session.username)
|
|
}
|
|
}
|
|
|
|
function toSessionItem(session: ChatSession): McpSessionItem {
|
|
return {
|
|
...toSessionRef(session),
|
|
lastMessagePreview: session.summary || '',
|
|
unreadCount: Number(session.unreadCount || 0),
|
|
lastTimestamp: Number(session.lastTimestamp || 0),
|
|
lastTimestampMs: toTimestampMs(Number(session.lastTimestamp || 0))
|
|
}
|
|
}
|
|
|
|
function toContactRef(contact: ContactWithLastContact): McpContactRef {
|
|
return {
|
|
contactId: contact.username,
|
|
sessionId: contact.username,
|
|
displayName: contact.displayName,
|
|
remark: contact.remark || undefined,
|
|
nickname: contact.nickname || undefined,
|
|
kind: contact.type as McpContactKind
|
|
}
|
|
}
|
|
|
|
function toContactItem(contact: ContactWithLastContact, hasSession: boolean): McpContactItem {
|
|
const lastContactTimestamp = Number(contact.lastContactTime || 0)
|
|
return {
|
|
contactId: contact.username,
|
|
sessionId: contact.username,
|
|
hasSession,
|
|
displayName: contact.displayName,
|
|
remark: contact.remark || undefined,
|
|
nickname: contact.nickname || undefined,
|
|
kind: contact.type as McpContactKind,
|
|
lastContactTimestamp,
|
|
lastContactTimestampMs: toTimestampMs(lastContactTimestamp)
|
|
}
|
|
}
|
|
|
|
function buildContactSearchKeys(contact: McpContactRef): string[] {
|
|
return [
|
|
contact.contactId,
|
|
contact.sessionId,
|
|
contact.displayName,
|
|
contact.remark || '',
|
|
contact.nickname || ''
|
|
]
|
|
.map((value) => String(value || '').trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function uniqueStrings(values: string[]): string[] {
|
|
const seen = new Set<string>()
|
|
const result: string[] = []
|
|
for (const value of values) {
|
|
const trimmed = String(value || '').trim()
|
|
if (!trimmed) continue
|
|
const normalized = normalizeQuery(trimmed)
|
|
if (!normalized || seen.has(normalized)) continue
|
|
seen.add(normalized)
|
|
result.push(trimmed)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function isSubsequence(query: string, target: string): boolean {
|
|
let qi = 0
|
|
let ti = 0
|
|
while (qi < query.length && ti < target.length) {
|
|
if (query[qi] === target[ti]) qi += 1
|
|
ti += 1
|
|
}
|
|
return qi === query.length
|
|
}
|
|
|
|
function scoreLookupValue(query: string, rawTarget: string): number {
|
|
const target = normalizeQuery(rawTarget)
|
|
if (!query || !target) return 0
|
|
if (target === query) return 1000
|
|
if (target.startsWith(query)) return 820 + Math.min(query.length * 8, 120)
|
|
if (target.includes(query)) return 640 + Math.min(query.length * 6, 100) - Math.min(Math.max(target.length - query.length, 0), 80)
|
|
if (query.startsWith(target)) return 420 + Math.min(target.length * 5, 80)
|
|
if (isSubsequence(query, target)) return 260 + Math.min(query.length * 4, 60)
|
|
return 0
|
|
}
|
|
|
|
function buildSessionLookupEntries(
|
|
sessions: McpSessionItem[],
|
|
contacts: McpContactRef[]
|
|
): McpSessionLookupEntry[] {
|
|
const entryMap = new Map<string, McpSessionLookupEntry>()
|
|
|
|
for (const session of sessions) {
|
|
entryMap.set(session.sessionId, {
|
|
session: {
|
|
sessionId: session.sessionId,
|
|
displayName: session.displayName,
|
|
kind: session.kind
|
|
},
|
|
aliases: uniqueStrings([session.sessionId, session.displayName])
|
|
})
|
|
}
|
|
|
|
for (const contact of contacts) {
|
|
const entry = entryMap.get(contact.sessionId)
|
|
if (!entry) continue
|
|
entry.aliases = uniqueStrings([
|
|
...entry.aliases,
|
|
contact.contactId,
|
|
contact.displayName,
|
|
contact.remark || '',
|
|
contact.nickname || ''
|
|
])
|
|
}
|
|
|
|
return Array.from(entryMap.values())
|
|
}
|
|
|
|
function formatSessionCandidateHint(rawInput: string, candidates: McpSessionLookupEntry[]): string {
|
|
if (candidates.length === 0) {
|
|
return `未找到与“${rawInput}”匹配的会话。可先用 list_sessions 或 list_contacts 做泛搜索。`
|
|
}
|
|
|
|
const preview = candidates
|
|
.slice(0, 5)
|
|
.map((candidate) => `- ${candidate.session.displayName} (${candidate.session.sessionId})`)
|
|
.join('\n')
|
|
|
|
return `“${rawInput}”匹配到多个候选,请改用更具体的信息重试:\n${preview}`
|
|
}
|
|
|
|
async function reportProgress(reporter: McpStreamReporter | undefined, payload: McpStreamProgressPayload): Promise<void> {
|
|
await reporter?.progress?.(payload)
|
|
}
|
|
|
|
async function reportPartial<K extends keyof McpStreamPartialPayloadMap>(
|
|
reporter: McpStreamReporter | undefined,
|
|
toolName: K,
|
|
payload: McpStreamPartialPayloadMap[K]
|
|
): Promise<void> {
|
|
await reporter?.partial?.(toolName, payload)
|
|
}
|
|
|
|
async function getContactCatalog(): Promise<{ items: McpContactRef[]; map: Map<string, McpContactRef> }> {
|
|
const result = await chatService.getContacts()
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const items = (result.contacts || []).map((contact) => toContactRef(contact as ContactWithLastContact))
|
|
const map = new Map<string, McpContactRef>()
|
|
|
|
for (const item of items) {
|
|
for (const key of buildContactSearchKeys(item)) {
|
|
map.set(normalizeQuery(key), item)
|
|
}
|
|
}
|
|
|
|
return { items, map }
|
|
}
|
|
|
|
function tryResolveContactRef(
|
|
rawValue: string,
|
|
contactMap: Map<string, McpContactRef>
|
|
): McpContactRef | null {
|
|
const normalized = normalizeQuery(rawValue)
|
|
if (!normalized) return null
|
|
|
|
const exact = contactMap.get(normalized)
|
|
if (exact) return exact
|
|
|
|
const partialMatches = Array.from(new Set(
|
|
Array.from(contactMap.values()).filter((contact) =>
|
|
buildContactSearchKeys(contact).some((value) => normalizeQuery(value).includes(normalized))
|
|
)
|
|
)) as McpContactRef[]
|
|
|
|
return partialMatches.length === 1 ? partialMatches[0] : null
|
|
}
|
|
|
|
function findSessionCandidates(
|
|
rawInput: string,
|
|
sessions: McpSessionItem[],
|
|
contacts: McpContactRef[]
|
|
): ScoredSessionCandidate[] {
|
|
const query = normalizeQuery(rawInput)
|
|
if (!query) return []
|
|
|
|
return buildSessionLookupEntries(sessions, contacts)
|
|
.map((entry) => ({
|
|
entry,
|
|
score: Math.max(...entry.aliases.map((alias) => scoreLookupValue(query, alias)), 0)
|
|
}))
|
|
.filter((item) => item.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.entry.session.displayName.localeCompare(b.entry.session.displayName, 'zh-CN'))
|
|
}
|
|
|
|
function toCandidateConfidence(score: number): 'high' | 'medium' | 'low' {
|
|
if (score >= 1000 || score >= 820) return 'high'
|
|
if (score >= 640) return 'medium'
|
|
return 'low'
|
|
}
|
|
|
|
function buildCandidateEvidence(candidate: ScoredSessionCandidate, query: string): string[] {
|
|
const normalizedQuery = normalizeQuery(query)
|
|
const evidence: string[] = []
|
|
|
|
for (const alias of candidate.entry.aliases) {
|
|
const normalizedAlias = normalizeQuery(alias)
|
|
if (!normalizedAlias) continue
|
|
if (normalizedAlias === normalizedQuery) {
|
|
evidence.push(`Exact alias match: ${alias}`)
|
|
} else if (normalizedAlias.startsWith(normalizedQuery)) {
|
|
evidence.push(`Prefix alias match: ${alias}`)
|
|
} else if (normalizedAlias.includes(normalizedQuery)) {
|
|
evidence.push(`Fuzzy alias match: ${alias}`)
|
|
} else if (isSubsequence(normalizedQuery, normalizedAlias)) {
|
|
evidence.push(`Subsequence alias match: ${alias}`)
|
|
}
|
|
|
|
if (evidence.length >= 3) break
|
|
}
|
|
|
|
if (candidate.score >= 1000) {
|
|
evidence.push('High-confidence score from exact resolution.')
|
|
} else if (candidate.score >= 820) {
|
|
evidence.push('High-confidence score from strong fuzzy match.')
|
|
} else if (candidate.score >= 640) {
|
|
evidence.push('Medium-confidence score from partial fuzzy match.')
|
|
}
|
|
|
|
return uniqueStrings(evidence).slice(0, 4)
|
|
}
|
|
|
|
function toResolvedCandidate(candidate: ScoredSessionCandidate, query: string): McpResolvedSessionCandidate {
|
|
return {
|
|
...candidate.entry.session,
|
|
score: candidate.score,
|
|
confidence: toCandidateConfidence(candidate.score),
|
|
aliases: candidate.entry.aliases,
|
|
evidence: buildCandidateEvidence(candidate, query)
|
|
}
|
|
}
|
|
|
|
function buildSearchSessionSummaries(hits: McpSearchHit[]): McpSearchMessagesPayload['sessionSummaries'] {
|
|
const grouped = new Map<string, {
|
|
session: McpSessionRef
|
|
hitCount: number
|
|
topScore: number
|
|
sampleExcerpts: string[]
|
|
}>()
|
|
|
|
for (const hit of hits) {
|
|
const key = hit.session.sessionId
|
|
const existing = grouped.get(key)
|
|
if (!existing) {
|
|
grouped.set(key, {
|
|
session: hit.session,
|
|
hitCount: 1,
|
|
topScore: hit.score,
|
|
sampleExcerpts: hit.excerpt ? [hit.excerpt] : []
|
|
})
|
|
continue
|
|
}
|
|
|
|
existing.hitCount += 1
|
|
existing.topScore = Math.max(existing.topScore, hit.score)
|
|
if (hit.excerpt && existing.sampleExcerpts.length < 2 && !existing.sampleExcerpts.includes(hit.excerpt)) {
|
|
existing.sampleExcerpts.push(hit.excerpt)
|
|
}
|
|
}
|
|
|
|
return Array.from(grouped.values())
|
|
.sort((a, b) => b.hitCount - a.hitCount || b.topScore - a.topScore)
|
|
}
|
|
|
|
function getDefaultExportPath(): string | null {
|
|
const config = new ConfigService()
|
|
try {
|
|
const exportPath = String(config.get('exportPath') || '').trim()
|
|
return exportPath || null
|
|
} finally {
|
|
config.close()
|
|
}
|
|
}
|
|
|
|
function isWritableDirectory(dir: string): boolean {
|
|
try {
|
|
if (!dir) return false
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
accessSync(dir, constants.W_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function isCompleteMediaOptions(
|
|
mediaOptions?: ExportChatArgs['mediaOptions']
|
|
): mediaOptions is McpExportMediaOptions {
|
|
return Boolean(
|
|
mediaOptions
|
|
&& typeof mediaOptions.exportAvatars === 'boolean'
|
|
&& typeof mediaOptions.exportImages === 'boolean'
|
|
&& typeof mediaOptions.exportVideos === 'boolean'
|
|
&& typeof mediaOptions.exportEmojis === 'boolean'
|
|
&& typeof mediaOptions.exportVoices === 'boolean'
|
|
)
|
|
}
|
|
|
|
function getNextExportQuestion(missingFields: McpExportMissingField[]): string | undefined {
|
|
if (missingFields.includes('session')) {
|
|
return '请先确认要导出哪个会话,可以提供 sessionId 或更具体的联系人线索。'
|
|
}
|
|
if (missingFields.includes('dateRange')) {
|
|
return '请补充导出的时间范围,至少需要开始时间和结束时间。'
|
|
}
|
|
if (missingFields.includes('format')) {
|
|
return '请确认导出格式,仅支持 chatlab、chatlab-jsonl、json、excel、html。'
|
|
}
|
|
if (missingFields.includes('mediaOptions')) {
|
|
return '请明确是否导出头像、图片、视频、表情、语音。'
|
|
}
|
|
if (missingFields.includes('outputDir')) {
|
|
return '默认导出目录不可用,请提供一个可写入的导出目录。'
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function buildExportFollowUpQuestions(missingFields: McpExportMissingField[]): Array<{
|
|
field: McpExportMissingField
|
|
question: string
|
|
}> {
|
|
const questions: Array<{ field: McpExportMissingField; question: string }> = []
|
|
|
|
for (const field of missingFields) {
|
|
if (field === 'session') {
|
|
questions.push({
|
|
field,
|
|
question: '你要导出哪个会话?可以给我更具体的联系人、备注名或 sessionId。'
|
|
})
|
|
} else if (field === 'dateRange') {
|
|
questions.push({
|
|
field,
|
|
question: '这次导出的时间范围是什么?请给我开始时间和结束时间。'
|
|
})
|
|
} else if (field === 'format') {
|
|
questions.push({
|
|
field,
|
|
question: '你要导出成哪种格式?目前支持 chatlab、chatlab-jsonl、json、excel、html。'
|
|
})
|
|
} else if (field === 'mediaOptions') {
|
|
questions.push({
|
|
field,
|
|
question: '媒体要怎么导?请分别确认是否包含头像、图片、视频、表情、语音。'
|
|
})
|
|
} else if (field === 'outputDir') {
|
|
questions.push({
|
|
field,
|
|
question: '默认导出目录不可用,请给我一个可写入的导出目录。'
|
|
})
|
|
}
|
|
}
|
|
|
|
return questions
|
|
}
|
|
|
|
function buildPredictedExportPath(
|
|
outputDir: string,
|
|
resolvedSession: Pick<McpResolvedSessionCandidate, 'displayName'>,
|
|
format: McpExportFormat,
|
|
mediaOptions: McpExportMediaOptions
|
|
): string {
|
|
const safeName = resolvedSession.displayName.replace(/[<>:"/\\|?*]/g, '_').replace(/\.+$/, '').trim() || 'export'
|
|
const ext = format === 'chatlab-jsonl'
|
|
? '.jsonl'
|
|
: format === 'excel'
|
|
? '.xlsx'
|
|
: format === 'html'
|
|
? '.html'
|
|
: '.json'
|
|
const hasMedia = mediaOptions.exportImages || mediaOptions.exportVideos || mediaOptions.exportEmojis || mediaOptions.exportVoices
|
|
const sessionOutputDir = hasMedia ? join(outputDir, safeName) : outputDir
|
|
return join(sessionOutputDir, `${safeName}${ext}`)
|
|
}
|
|
|
|
function toExportServiceOptions(
|
|
format: McpExportFormat,
|
|
dateRange: McpExportDateRange,
|
|
mediaOptions: McpExportMediaOptions
|
|
): ExportServiceOptions {
|
|
return {
|
|
format,
|
|
dateRange,
|
|
exportAvatars: mediaOptions.exportAvatars,
|
|
exportImages: mediaOptions.exportImages,
|
|
exportVideos: mediaOptions.exportVideos,
|
|
exportEmojis: mediaOptions.exportEmojis,
|
|
exportVoices: mediaOptions.exportVoices
|
|
}
|
|
}
|
|
|
|
function resolveSessionRefStrict(
|
|
rawInput: string,
|
|
sessions: McpSessionItem[],
|
|
sessionMap: Map<string, McpSessionRef>,
|
|
contacts: McpContactRef[],
|
|
contactMap: Map<string, McpContactRef>
|
|
): McpSessionRef {
|
|
const direct = resolveSessionRef(rawInput, sessionMap, contactMap)
|
|
if (sessionMap.has(direct.sessionId)) {
|
|
return direct
|
|
}
|
|
|
|
const candidates = findSessionCandidates(rawInput, sessions, contacts)
|
|
if (candidates.length === 0) {
|
|
throw new McpToolError('SESSION_NOT_FOUND', 'Session not found.', formatSessionCandidateHint(rawInput, []))
|
|
}
|
|
|
|
const [first, second] = candidates
|
|
if (candidates.length === 1 || !second || first.score - second.score >= 140 || first.score >= 1000) {
|
|
return first.entry.session
|
|
}
|
|
|
|
throw new McpToolError('BAD_REQUEST', 'Session is ambiguous.', formatSessionCandidateHint(
|
|
rawInput,
|
|
candidates.map((item) => item.entry)
|
|
))
|
|
}
|
|
|
|
async function resolveSessionRefStrictWithProgress(
|
|
rawInput: string,
|
|
sessions: McpSessionItem[],
|
|
sessionMap: Map<string, McpSessionRef>,
|
|
contacts: McpContactRef[],
|
|
contactMap: Map<string, McpContactRef>,
|
|
reporter?: McpStreamReporter
|
|
): Promise<McpSessionRef> {
|
|
await reportProgress(reporter, {
|
|
stage: 'resolving_input',
|
|
message: `Resolving session reference from "${rawInput}".`
|
|
})
|
|
await reportProgress(reporter, {
|
|
stage: 'searching_contacts',
|
|
message: 'Searching contacts and aliases.'
|
|
})
|
|
await reportProgress(reporter, {
|
|
stage: 'searching_sessions',
|
|
message: 'Searching sessions and recent conversation entries.'
|
|
})
|
|
|
|
const direct = resolveSessionRef(rawInput, sessionMap, contactMap)
|
|
if (sessionMap.has(direct.sessionId)) {
|
|
await reportProgress(reporter, {
|
|
stage: 'resolving_candidates',
|
|
message: `Resolved to ${direct.displayName}.`,
|
|
candidates: [direct],
|
|
candidateCount: 1
|
|
})
|
|
return direct
|
|
}
|
|
|
|
const candidates = findSessionCandidates(rawInput, sessions, contacts)
|
|
await reportProgress(reporter, {
|
|
stage: 'resolving_candidates',
|
|
message: candidates.length > 0 ? `Found ${candidates.length} candidate sessions.` : 'No candidate sessions found.',
|
|
candidates: candidates.slice(0, 5).map((item) => item.entry.session),
|
|
candidateCount: candidates.length
|
|
})
|
|
|
|
return resolveSessionRefStrict(rawInput, sessions, sessionMap, contacts, contactMap)
|
|
}
|
|
|
|
function resolveSessionRef(
|
|
rawSessionId: string,
|
|
sessionMap: Map<string, McpSessionRef>,
|
|
contactMap?: Map<string, McpContactRef>
|
|
): McpSessionRef {
|
|
const directSession = sessionMap.get(rawSessionId)
|
|
if (directSession) return directSession
|
|
|
|
const contact = contactMap ? tryResolveContactRef(rawSessionId, contactMap) : null
|
|
if (contact) {
|
|
return sessionMap.get(contact.sessionId) || {
|
|
sessionId: contact.sessionId,
|
|
displayName: contact.displayName || contact.sessionId,
|
|
kind: detectSessionKind(contact.sessionId)
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId: rawSessionId,
|
|
displayName: rawSessionId,
|
|
kind: detectSessionKind(rawSessionId)
|
|
}
|
|
}
|
|
|
|
function mapChatError(errorMessage?: string): never {
|
|
const message = errorMessage || 'Unknown chat service error.'
|
|
|
|
if (
|
|
message.includes('请先在设置页面配置微信ID') ||
|
|
message.includes('请先解密数据库') ||
|
|
message.includes('未找到账号') ||
|
|
message.includes('未找到 session.db') ||
|
|
message.includes('未找到会话表') ||
|
|
message.includes('数据库未连接') ||
|
|
message.includes('联系人数据库未连接')
|
|
) {
|
|
throw new McpToolError('DB_NOT_READY', 'Chat database is not ready.', message)
|
|
}
|
|
|
|
if (message.includes('未找到该会话的消息表')) {
|
|
throw new McpToolError('SESSION_NOT_FOUND', 'Session not found.', message)
|
|
}
|
|
|
|
throw new McpToolError('INTERNAL_ERROR', 'Failed to query CipherTalk data.', message)
|
|
}
|
|
|
|
async function getEmojiLocalPath(message: Message): Promise<string | null> {
|
|
if (!message.emojiMd5 && !message.emojiCdnUrl) return null
|
|
|
|
try {
|
|
const result = await chatService.downloadEmoji(
|
|
String(message.emojiCdnUrl || ''),
|
|
message.emojiMd5,
|
|
message.productId,
|
|
Number(message.createTime || 0)
|
|
)
|
|
|
|
return result.success ? result.cachePath || result.localPath || null : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getImageLocalPath(sessionId: string, message: Message): Promise<string | null> {
|
|
if (!message.imageMd5 && !message.imageDatName) return null
|
|
|
|
try {
|
|
const resolved = await imageDecryptService.resolveCachedImage({
|
|
sessionId,
|
|
imageMd5: message.imageMd5,
|
|
imageDatName: message.imageDatName
|
|
})
|
|
|
|
if (resolved.success && resolved.localPath) {
|
|
return resolved.localPath
|
|
}
|
|
|
|
const decrypted = await imageDecryptService.decryptImage({
|
|
sessionId,
|
|
imageMd5: message.imageMd5,
|
|
imageDatName: message.imageDatName,
|
|
force: false
|
|
})
|
|
|
|
return decrypted.success ? decrypted.localPath || null : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getVideoLocalPath(message: Message): string | null {
|
|
if (!message.videoMd5) return null
|
|
|
|
try {
|
|
const info = videoService.getVideoInfo(String(message.videoMd5))
|
|
return info.exists ? info.videoUrl || null : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getVoiceLocalPath(sessionId: string, message: Message): Promise<string | null> {
|
|
const localId = Number(message.localId || 0)
|
|
const createTime = Number(message.createTime || 0)
|
|
if (!localId || !createTime) return null
|
|
|
|
try {
|
|
const voiceResult = await chatService.getVoiceData(sessionId, String(localId), createTime)
|
|
if (!voiceResult.success || !voiceResult.data) return null
|
|
|
|
const configService = new ConfigService()
|
|
const cachePath = String(configService.get('cachePath') || '')
|
|
configService.close()
|
|
|
|
const baseDir = cachePath || join(process.cwd(), 'cache')
|
|
const voiceDir = join(baseDir, 'McpVoices', sessionId.replace(/[\\/:*?"<>|]/g, '_'))
|
|
if (!existsSync(voiceDir)) {
|
|
mkdirSync(voiceDir, { recursive: true })
|
|
}
|
|
|
|
const absolutePath = join(voiceDir, `${createTime}_${localId}.wav`)
|
|
await writeFile(absolutePath, Buffer.from(voiceResult.data, 'base64'))
|
|
return absolutePath
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getFileLocalPath(message: Message): string | null {
|
|
const fileName = String(message.fileName || '')
|
|
if (!fileName) return null
|
|
|
|
const configService = new ConfigService()
|
|
try {
|
|
const dbPath = String(configService.get('dbPath') || '')
|
|
const myWxid = String(configService.get('myWxid') || '')
|
|
if (!dbPath || !myWxid) return null
|
|
|
|
const createTimeMs = toTimestampMs(Number(message.createTime || 0))
|
|
const fileDate = createTimeMs ? new Date(createTimeMs) : new Date()
|
|
const monthDir = `${fileDate.getFullYear()}-${String(fileDate.getMonth() + 1).padStart(2, '0')}`
|
|
return join(dbPath, myWxid, 'msg', 'file', monthDir, fileName)
|
|
} finally {
|
|
configService.close()
|
|
}
|
|
}
|
|
|
|
async function normalizeMessage(
|
|
sessionId: string,
|
|
message: Message,
|
|
options: MessageNormalizeOptions
|
|
): Promise<McpMessageItem> {
|
|
const kind = detectMessageKind(message)
|
|
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
|
|
const normalized: McpMessageItem = {
|
|
messageId: Number(message.localId || message.serverId || 0),
|
|
timestamp: Number(message.createTime || 0),
|
|
timestampMs: toTimestampMs(Number(message.createTime || 0)),
|
|
direction,
|
|
kind,
|
|
text: String(message.parsedContent || message.rawContent || ''),
|
|
sender: {
|
|
username: message.senderUsername ?? null,
|
|
isSelf: direction === 'out'
|
|
},
|
|
cursor: buildCursor(message)
|
|
}
|
|
|
|
if (options.includeRaw) {
|
|
normalized.raw = String(message.rawContent || '')
|
|
}
|
|
|
|
switch (kind) {
|
|
case 'emoji':
|
|
normalized.media = {
|
|
type: 'emoji',
|
|
md5: message.emojiMd5 || null
|
|
}
|
|
if (options.includeMediaPaths) {
|
|
normalized.media.localPath = await getEmojiLocalPath(message)
|
|
}
|
|
break
|
|
case 'image':
|
|
normalized.media = {
|
|
type: 'image',
|
|
md5: message.imageMd5 || null,
|
|
isLivePhoto: Boolean(message.isLivePhoto)
|
|
}
|
|
if (options.includeMediaPaths) {
|
|
normalized.media.localPath = await getImageLocalPath(sessionId, message)
|
|
}
|
|
break
|
|
case 'video':
|
|
normalized.media = {
|
|
type: 'video',
|
|
md5: message.videoMd5 || null,
|
|
durationSeconds: Number(message.videoDuration || 0) || null,
|
|
isLivePhoto: Boolean(message.isLivePhoto)
|
|
}
|
|
if (options.includeMediaPaths) {
|
|
normalized.media.localPath = getVideoLocalPath(message)
|
|
}
|
|
break
|
|
case 'voice':
|
|
normalized.media = {
|
|
type: 'voice',
|
|
durationSeconds: Number(message.voiceDuration || 0) || null
|
|
}
|
|
if (options.includeMediaPaths) {
|
|
normalized.media.localPath = await getVoiceLocalPath(sessionId, message)
|
|
}
|
|
break
|
|
case 'app_file': {
|
|
const localPath = options.includeMediaPaths ? getFileLocalPath(message) : null
|
|
normalized.media = {
|
|
type: 'file',
|
|
md5: message.fileMd5 || null,
|
|
fileName: message.fileName || null,
|
|
fileSize: Number(message.fileSize || 0) || null,
|
|
localPath,
|
|
exists: localPath ? existsSync(localPath) : null
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
async function normalizeMessages(
|
|
sessionId: string,
|
|
messages: Message[],
|
|
options: MessageNormalizeOptions
|
|
): Promise<McpMessageItem[]> {
|
|
return Promise.all(messages.map((message) => normalizeMessage(sessionId, message, options)))
|
|
}
|
|
|
|
async function getSessionCatalog(): Promise<{ items: McpSessionItem[]; map: Map<string, McpSessionRef> }> {
|
|
const result = await chatService.getSessions()
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const items = (result.sessions || [])
|
|
.map((session) => toSessionItem(session))
|
|
.sort((a, b) => b.lastTimestamp - a.lastTimestamp || a.displayName.localeCompare(b.displayName, 'zh-CN'))
|
|
|
|
const map = new Map<string, McpSessionRef>()
|
|
for (const item of items) {
|
|
map.set(item.sessionId, {
|
|
sessionId: item.sessionId,
|
|
displayName: item.displayName,
|
|
kind: item.kind
|
|
})
|
|
}
|
|
|
|
return { items, map }
|
|
}
|
|
|
|
function messageMatchesFilters(
|
|
message: Message,
|
|
filters: {
|
|
startTimeMs?: number
|
|
endTimeMs?: number
|
|
kinds?: Set<McpMessageKind>
|
|
direction?: 'in' | 'out'
|
|
senderUsername?: string
|
|
}
|
|
): boolean {
|
|
const timestampMs = toTimestampMs(Number(message.createTime || 0))
|
|
if (filters.startTimeMs && timestampMs < filters.startTimeMs) return false
|
|
if (filters.endTimeMs && timestampMs > filters.endTimeMs) return false
|
|
|
|
if (filters.kinds?.size) {
|
|
const kind = detectMessageKind(message)
|
|
if (!filters.kinds.has(kind)) return false
|
|
}
|
|
|
|
if (filters.direction) {
|
|
const direction = Number(message.isSend) === 1 ? 'out' : 'in'
|
|
if (direction !== filters.direction) return false
|
|
}
|
|
|
|
if (filters.senderUsername) {
|
|
const senderUsername = String(message.senderUsername || '').trim().toLowerCase()
|
|
if (senderUsername !== filters.senderUsername) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export class McpReadService {
|
|
async resolveSession(rawArgs: ResolveSessionArgs, reporter?: McpStreamReporter): Promise<McpResolveSessionPayload> {
|
|
const args = resolveSessionArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid resolve_session arguments.', args.error.message)
|
|
}
|
|
|
|
const limit = Math.min(args.data.limit ?? 5, 10)
|
|
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
|
|
await reportProgress(reporter, {
|
|
stage: 'resolving_input',
|
|
message: `Resolving session candidates for "${args.data.query}".`
|
|
})
|
|
|
|
const direct = resolveSessionRef(args.data.query, sessionMap, contactMap)
|
|
let candidates = findSessionCandidates(args.data.query, sessions, contacts)
|
|
|
|
if (sessionMap.has(direct.sessionId) && !candidates.some((item) => item.entry.session.sessionId === direct.sessionId)) {
|
|
const directEntry = buildSessionLookupEntries(sessions, contacts).find((entry) => entry.session.sessionId === direct.sessionId)
|
|
if (directEntry) {
|
|
candidates = [{ entry: directEntry, score: 1000 }, ...candidates]
|
|
}
|
|
}
|
|
|
|
const dedupedCandidates = Array.from(new Map(
|
|
candidates.map((candidate) => [candidate.entry.session.sessionId, candidate])
|
|
).values()).slice(0, limit)
|
|
|
|
await reportProgress(reporter, {
|
|
stage: 'resolving_candidates',
|
|
message: dedupedCandidates.length > 0 ? `Found ${dedupedCandidates.length} session candidates.` : 'No session candidates found.',
|
|
candidates: dedupedCandidates.map((candidate) => candidate.entry.session),
|
|
candidateCount: dedupedCandidates.length
|
|
})
|
|
|
|
const recommended = dedupedCandidates[0] ? toResolvedCandidate(dedupedCandidates[0], args.data.query) : undefined
|
|
const exact = Boolean(recommended && recommended.score >= 1000)
|
|
const resolved = Boolean(recommended && (dedupedCandidates.length === 1 || recommended.confidence === 'high'))
|
|
|
|
const payload: McpResolveSessionPayload = {
|
|
query: args.data.query,
|
|
resolved,
|
|
exact,
|
|
recommended,
|
|
candidates: dedupedCandidates.map((candidate) => toResolvedCandidate(candidate, args.data.query)),
|
|
suggestedNextAction: resolved
|
|
? 'get_session_context'
|
|
: dedupedCandidates.length > 0
|
|
? 'search_messages'
|
|
: 'list_contacts',
|
|
message: resolved
|
|
? `Resolved "${args.data.query}" to ${recommended?.displayName}.`
|
|
: dedupedCandidates.length > 0
|
|
? `Found ${dedupedCandidates.length} plausible session candidates for "${args.data.query}".`
|
|
: `No session candidates found for "${args.data.query}".`
|
|
}
|
|
|
|
await reportPartial(reporter, 'resolve_session', payload)
|
|
return payload
|
|
}
|
|
|
|
async exportChat(rawArgs: ExportChatArgs, reporter?: McpStreamReporter): Promise<McpExportChatPayload> {
|
|
const args = exportChatArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid export_chat arguments.', args.error.message)
|
|
}
|
|
|
|
const data = args.data
|
|
const validateOnly = Boolean(data.validateOnly)
|
|
await reportProgress(reporter, {
|
|
stage: 'validating_export_request',
|
|
message: 'Validating export request.'
|
|
})
|
|
|
|
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
|
|
let resolvedSession: McpResolvedSessionCandidate | undefined
|
|
let candidates: McpResolvedSessionCandidate[] = []
|
|
|
|
if (data.sessionId || data.query) {
|
|
const query = data.sessionId || data.query || ''
|
|
const matchedCandidates = findSessionCandidates(query, sessions, contacts).slice(0, 5)
|
|
candidates = matchedCandidates.map((candidate) => toResolvedCandidate(candidate, query))
|
|
|
|
try {
|
|
const resolved = await resolveSessionRefStrictWithProgress(query, sessions, sessionMap, contacts, contactMap, reporter)
|
|
const matched = matchedCandidates.find((candidate) => candidate.entry.session.sessionId === resolved.sessionId)
|
|
resolvedSession = matched ? toResolvedCandidate(matched, query) : {
|
|
...resolved,
|
|
score: 1000,
|
|
confidence: 'high',
|
|
aliases: [resolved.displayName, resolved.sessionId],
|
|
evidence: ['Resolved directly from the provided session clue.']
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof McpToolError) || (error.code !== 'BAD_REQUEST' && error.code !== 'SESSION_NOT_FOUND')) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
const missingFields: McpExportMissingField[] = []
|
|
if (!resolvedSession) {
|
|
missingFields.push('session')
|
|
}
|
|
if (!data.dateRange || !data.dateRange.start || !data.dateRange.end) {
|
|
missingFields.push('dateRange')
|
|
} else if (data.dateRange.start > data.dateRange.end) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid export date range.', 'dateRange.start must be earlier than or equal to dateRange.end.')
|
|
}
|
|
if (!data.format) {
|
|
missingFields.push('format')
|
|
} else if (!SUPPORTED_EXPORT_FORMATS.includes(data.format)) {
|
|
throw new McpToolError('BAD_REQUEST', 'Unsupported export format.', `Only ${SUPPORTED_EXPORT_FORMATS.join(', ')} are supported.`)
|
|
}
|
|
if (!isCompleteMediaOptions(data.mediaOptions)) {
|
|
missingFields.push('mediaOptions')
|
|
}
|
|
|
|
const requestedOutputDir = String(data.outputDir || '').trim()
|
|
const outputDir = requestedOutputDir || getDefaultExportPath() || ''
|
|
if (!outputDir || !isWritableDirectory(outputDir)) {
|
|
missingFields.push('outputDir')
|
|
}
|
|
|
|
const nextQuestion = getNextExportQuestion(missingFields)
|
|
const followUpQuestions = buildExportFollowUpQuestions(missingFields)
|
|
const payload: McpExportChatPayload = {
|
|
canExport: missingFields.length === 0,
|
|
validateOnly,
|
|
missingFields,
|
|
nextQuestion,
|
|
followUpQuestions,
|
|
resolvedSession,
|
|
candidates,
|
|
outputDir: outputDir || undefined,
|
|
format: data.format,
|
|
dateRange: data.dateRange,
|
|
mediaOptions: isCompleteMediaOptions(data.mediaOptions) ? data.mediaOptions : undefined,
|
|
message: missingFields.length === 0
|
|
? validateOnly
|
|
? 'Export request is complete and ready to run.'
|
|
: 'Export request validated and ready to execute.'
|
|
: 'Export request is incomplete and needs more information.'
|
|
}
|
|
|
|
await reportPartial(reporter, 'export_chat', payload)
|
|
|
|
if (missingFields.length > 0 || validateOnly) {
|
|
return payload
|
|
}
|
|
|
|
await reportProgress(reporter, {
|
|
stage: 'preparing_export',
|
|
message: `Preparing export for ${resolvedSession!.displayName}.`,
|
|
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
|
|
candidateCount: 1
|
|
})
|
|
|
|
const exportOptions = toExportServiceOptions(
|
|
data.format!,
|
|
data.dateRange!,
|
|
data.mediaOptions as McpExportMediaOptions
|
|
)
|
|
|
|
const predictedOutputPath = buildPredictedExportPath(
|
|
outputDir,
|
|
resolvedSession!,
|
|
data.format!,
|
|
data.mediaOptions as McpExportMediaOptions
|
|
)
|
|
|
|
const result = await exportService.exportSessions(
|
|
[resolvedSession!.sessionId],
|
|
outputDir,
|
|
exportOptions,
|
|
(progress) => {
|
|
const stage = progress.phase === 'writing'
|
|
? 'writing'
|
|
: progress.phase === 'exporting'
|
|
? 'exporting'
|
|
: progress.phase === 'complete'
|
|
? 'completed'
|
|
: 'preparing_export'
|
|
|
|
void reportProgress(reporter, {
|
|
stage,
|
|
message: progress.detail || progress.phase,
|
|
sessionsScanned: progress.current,
|
|
candidates: [{ sessionId: resolvedSession!.sessionId, displayName: resolvedSession!.displayName, kind: resolvedSession!.kind }],
|
|
candidateCount: 1
|
|
})
|
|
}
|
|
)
|
|
|
|
const completedPayload: McpExportChatPayload = {
|
|
...payload,
|
|
canExport: true,
|
|
success: result.success,
|
|
successCount: result.successCount,
|
|
failCount: result.failCount,
|
|
error: result.error,
|
|
outputPath: predictedOutputPath,
|
|
message: result.success
|
|
? `Exported chat for ${resolvedSession!.displayName}.`
|
|
: `Failed to export chat for ${resolvedSession!.displayName}.`
|
|
}
|
|
|
|
await reportPartial(reporter, 'export_chat', completedPayload)
|
|
return completedPayload
|
|
}
|
|
|
|
async listSessions(rawArgs: ListSessionsArgs, reporter?: McpStreamReporter): Promise<McpSessionsPayload> {
|
|
const args = listSessionsArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid list_sessions arguments.', args.error.message)
|
|
}
|
|
|
|
const query = normalizeQuery(args.data.q)
|
|
const offset = Math.max(0, args.data.offset ?? 0)
|
|
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
|
|
const unreadOnly = Boolean(args.data.unreadOnly)
|
|
await reportProgress(reporter, {
|
|
stage: 'searching_sessions',
|
|
message: query ? `Searching sessions for "${args.data.q}".` : 'Listing sessions.'
|
|
})
|
|
|
|
const [{ items: sessionItems }, { map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
|
|
let sessions = sessionItems
|
|
|
|
if (query) {
|
|
sessions = sessions.filter((session) => {
|
|
return [
|
|
session.sessionId,
|
|
session.displayName,
|
|
session.lastMessagePreview,
|
|
...buildContactSearchKeys(contactMap.get(normalizeQuery(session.sessionId)) || {
|
|
contactId: session.sessionId,
|
|
sessionId: session.sessionId,
|
|
displayName: '',
|
|
remark: '',
|
|
nickname: '',
|
|
kind: session.kind === 'group' ? 'group' : session.kind === 'official' ? 'official' : 'friend'
|
|
})
|
|
].some((value) => value.toLowerCase().includes(query))
|
|
})
|
|
}
|
|
|
|
if (unreadOnly) {
|
|
sessions = sessions.filter((session) => session.unreadCount > 0)
|
|
}
|
|
|
|
const total = sessions.length
|
|
const items = sessions.slice(offset, offset + limit)
|
|
await reportPartial(reporter, 'list_sessions', {
|
|
items,
|
|
total,
|
|
offset,
|
|
limit,
|
|
hasMore: offset + items.length < total
|
|
})
|
|
|
|
return {
|
|
items,
|
|
total,
|
|
offset,
|
|
limit,
|
|
hasMore: offset + items.length < total
|
|
}
|
|
}
|
|
|
|
async listContacts(rawArgs: ListContactsArgs, reporter?: McpStreamReporter): Promise<McpContactsPayload> {
|
|
const args = listContactsArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid list_contacts arguments.', args.error.message)
|
|
}
|
|
|
|
const query = normalizeQuery(args.data.q)
|
|
const offset = Math.max(0, args.data.offset ?? 0)
|
|
const limit = Math.min(args.data.limit ?? 100, MAX_LIST_LIMIT)
|
|
const typeSet = args.data.types?.length ? new Set(args.data.types) : null
|
|
await reportProgress(reporter, {
|
|
stage: 'searching_contacts',
|
|
message: query ? `Searching contacts for "${args.data.q}".` : 'Listing contacts.'
|
|
})
|
|
|
|
const result = await chatService.getContacts()
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const { map: sessionMap } = await getSessionCatalog()
|
|
let contacts = (result.contacts || []).map((contact) => {
|
|
const typedContact = contact as ContactWithLastContact
|
|
return toContactItem(typedContact, sessionMap.has(typedContact.username))
|
|
})
|
|
|
|
if (typeSet) {
|
|
contacts = contacts.filter((contact) => typeSet.has(contact.kind))
|
|
}
|
|
|
|
if (query) {
|
|
contacts = contacts.filter((contact) => {
|
|
return [
|
|
contact.contactId,
|
|
contact.displayName,
|
|
contact.remark || '',
|
|
contact.nickname || ''
|
|
].some((value) => value.toLowerCase().includes(query))
|
|
})
|
|
}
|
|
|
|
const total = contacts.length
|
|
const items = contacts.slice(offset, offset + limit)
|
|
await reportPartial(reporter, 'list_contacts', {
|
|
items,
|
|
total,
|
|
offset,
|
|
limit,
|
|
hasMore: offset + items.length < total
|
|
})
|
|
|
|
return {
|
|
items,
|
|
total,
|
|
offset,
|
|
limit,
|
|
hasMore: offset + items.length < total
|
|
}
|
|
}
|
|
|
|
async getGlobalStatistics(rawArgs: z.infer<typeof analyticsTimeRangeArgsSchema>): Promise<McpGlobalStatisticsPayload> {
|
|
const args = analyticsTimeRangeArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid get_global_statistics arguments.', args.error.message)
|
|
}
|
|
|
|
const result = await analyticsService.getOverallStatistics(args.data.startTime, args.data.endTime)
|
|
if (!result.success || !result.data) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
return {
|
|
...result.data,
|
|
firstMessageTimeMs: result.data.firstMessageTime ? toTimestampMs(result.data.firstMessageTime) : null,
|
|
lastMessageTimeMs: result.data.lastMessageTime ? toTimestampMs(result.data.lastMessageTime) : null,
|
|
timeRange: buildTimeRange(args.data.startTime, args.data.endTime)
|
|
}
|
|
}
|
|
|
|
async getContactRankings(rawArgs: z.infer<typeof contactRankingsArgsSchema>): Promise<McpContactRankingsPayload> {
|
|
const args = contactRankingsArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid get_contact_rankings arguments.', args.error.message)
|
|
}
|
|
|
|
const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT)
|
|
const result = await analyticsService.getContactRankings(limit, args.data.startTime, args.data.endTime)
|
|
if (!result.success || !result.data) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const items: McpContactRankingItem[] = result.data.map((item) => ({
|
|
contactId: item.username,
|
|
displayName: item.displayName,
|
|
avatarUrl: item.avatarUrl,
|
|
messageCount: item.messageCount,
|
|
sentCount: item.sentCount,
|
|
receivedCount: item.receivedCount,
|
|
lastMessageTime: item.lastMessageTime,
|
|
lastMessageTimeMs: item.lastMessageTime ? toTimestampMs(item.lastMessageTime) : null
|
|
}))
|
|
|
|
return {
|
|
items,
|
|
limit,
|
|
timeRange: buildTimeRange(args.data.startTime, args.data.endTime)
|
|
}
|
|
}
|
|
|
|
async getActivityDistribution(rawArgs: z.infer<typeof analyticsTimeRangeArgsSchema>): Promise<McpActivityDistributionPayload> {
|
|
const args = analyticsTimeRangeArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid get_activity_distribution arguments.', args.error.message)
|
|
}
|
|
|
|
const result = await analyticsService.getTimeDistribution(args.data.startTime, args.data.endTime)
|
|
if (!result.success || !result.data) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
return {
|
|
...result.data,
|
|
timeRange: buildTimeRange(args.data.startTime, args.data.endTime)
|
|
}
|
|
}
|
|
|
|
async getMessages(rawArgs: GetMessagesArgs, defaultIncludeMediaPaths: boolean, reporter?: McpStreamReporter): Promise<McpMessagesPayload> {
|
|
const args = getMessagesArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid get_messages arguments.', args.error.message)
|
|
}
|
|
|
|
const {
|
|
sessionId: rawSessionId,
|
|
keyword,
|
|
includeRaw = false,
|
|
order = 'asc'
|
|
} = args.data
|
|
|
|
const offset = Math.max(0, args.data.offset ?? 0)
|
|
const limit = Math.min(args.data.limit ?? 50, MAX_LIST_LIMIT)
|
|
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
|
const keywordQuery = normalizeQuery(keyword)
|
|
const startTimeMs = toTimestampMs(args.data.startTime)
|
|
const endTimeMs = toTimestampMs(args.data.endTime)
|
|
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
const session = await resolveSessionRefStrictWithProgress(rawSessionId, sessions, sessionMap, contacts, contactMap, reporter)
|
|
const sessionId = session.sessionId
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Scanning messages in ${session.displayName}.`,
|
|
sessionsScanned: 1,
|
|
messagesScanned: 0
|
|
})
|
|
|
|
const matched: Message[] = []
|
|
let scanOffset = 0
|
|
let scanned = 0
|
|
let reachedEnd = false
|
|
const targetCount = offset + limit + 1
|
|
|
|
while (scanned < 5000 && matched.length < targetCount) {
|
|
const result = await chatService.getMessages(sessionId, scanOffset, SEARCH_BATCH_SIZE)
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const part = result.messages || []
|
|
if (part.length === 0) {
|
|
reachedEnd = true
|
|
break
|
|
}
|
|
|
|
for (const message of part) {
|
|
if (!messageMatchesFilters(message, { startTimeMs, endTimeMs })) continue
|
|
if (keywordQuery && !findKeywordMatch(message, keywordQuery)) continue
|
|
matched.push(message)
|
|
}
|
|
|
|
scanOffset += part.length
|
|
scanned += part.length
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Scanned ${scanned} messages in ${session.displayName}.`,
|
|
sessionsScanned: 1,
|
|
messagesScanned: scanned
|
|
})
|
|
|
|
if (!result.hasMore) {
|
|
reachedEnd = true
|
|
break
|
|
}
|
|
}
|
|
|
|
matched.sort((a, b) => order === 'asc' ? compareMessageCursorAsc(a, b) : compareMessageCursorDesc(a, b))
|
|
|
|
const page = matched.slice(offset, offset + limit)
|
|
const items = await normalizeMessages(sessionId, page, { includeMediaPaths, includeRaw })
|
|
await reportPartial(reporter, 'get_messages', {
|
|
items,
|
|
offset,
|
|
limit,
|
|
hasMore: reachedEnd ? matched.length > offset + items.length : true
|
|
})
|
|
|
|
return {
|
|
items,
|
|
offset,
|
|
limit,
|
|
hasMore: reachedEnd ? matched.length > offset + items.length : true
|
|
}
|
|
}
|
|
|
|
async searchMessages(rawArgs: SearchMessagesArgs, defaultIncludeMediaPaths: boolean, reporter?: McpStreamReporter): Promise<McpSearchMessagesPayload> {
|
|
const args = searchMessagesArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid search_messages arguments.', args.error.message)
|
|
}
|
|
|
|
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
const includeRaw = args.data.includeRaw ?? false
|
|
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
|
const limit = Math.min(args.data.limit ?? 20, MAX_SEARCH_LIMIT)
|
|
const matchMode = args.data.matchMode ?? 'substring'
|
|
const sessionIdCandidates = Array.from(new Set([
|
|
...(args.data.sessionId ? [args.data.sessionId] : []),
|
|
...(args.data.sessionIds || [])
|
|
]))
|
|
|
|
if (sessionIdCandidates.length > MAX_SEARCH_SESSIONS) {
|
|
throw new McpToolError('BAD_REQUEST', `At most ${MAX_SEARCH_SESSIONS} sessionIds can be searched at once.`)
|
|
}
|
|
|
|
const targetSessions = sessionIdCandidates.length > 0
|
|
? await Promise.all(sessionIdCandidates.map((sessionId) => resolveSessionRefStrictWithProgress(sessionId, sessions, sessionMap, contacts, contactMap, reporter)))
|
|
: sessions.map((session) => ({
|
|
sessionId: session.sessionId,
|
|
displayName: session.displayName,
|
|
kind: session.kind
|
|
}))
|
|
|
|
const kindSet = args.data.kinds?.length ? new Set(args.data.kinds) : undefined
|
|
const senderUsername = normalizeQuery(args.data.senderUsername)
|
|
const startTimeMs = toTimestampMs(args.data.startTime)
|
|
const endTimeMs = toTimestampMs(args.data.endTime)
|
|
|
|
const rawHits: SearchRawHit[] = []
|
|
let sessionsScanned = 0
|
|
let messagesScanned = 0
|
|
let truncated = false
|
|
let bestScore = Number.NEGATIVE_INFINITY
|
|
let hitTargetReached = false
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Searching ${targetSessions.length} sessions for "${args.data.query}".`,
|
|
sessionsScanned: 0,
|
|
messagesScanned: 0
|
|
})
|
|
|
|
for (const session of targetSessions) {
|
|
sessionsScanned += 1
|
|
|
|
let sessionOffset = 0
|
|
let sessionScanned = 0
|
|
|
|
while (sessionScanned < MAX_SCAN_PER_SESSION && messagesScanned < MAX_SCAN_GLOBAL) {
|
|
const bestScoreBeforeBatch = bestScore
|
|
let roundBestScore = Number.NEGATIVE_INFINITY
|
|
|
|
const fetchLimit = Math.min(
|
|
SEARCH_BATCH_SIZE,
|
|
MAX_SCAN_PER_SESSION - sessionScanned,
|
|
MAX_SCAN_GLOBAL - messagesScanned
|
|
)
|
|
|
|
if (fetchLimit <= 0) {
|
|
truncated = true
|
|
break
|
|
}
|
|
|
|
const result = await chatService.getMessages(session.sessionId, sessionOffset, fetchLimit)
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const part = result.messages || []
|
|
if (part.length === 0) break
|
|
|
|
sessionOffset += part.length
|
|
sessionScanned += part.length
|
|
messagesScanned += part.length
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Scanned ${messagesScanned} messages across ${sessionsScanned} sessions.`,
|
|
sessionsScanned,
|
|
messagesScanned
|
|
})
|
|
|
|
for (const message of part) {
|
|
if (!messageMatchesFilters(message, {
|
|
startTimeMs,
|
|
endTimeMs,
|
|
kinds: kindSet,
|
|
direction: args.data.direction,
|
|
senderUsername
|
|
})) {
|
|
continue
|
|
}
|
|
|
|
const match = findKeywordMatch(message, args.data.query, matchMode)
|
|
if (!match) continue
|
|
|
|
rawHits.push({
|
|
session,
|
|
message,
|
|
matchedField: match.matchedField,
|
|
excerpt: match.excerpt,
|
|
score: match.score
|
|
})
|
|
roundBestScore = Math.max(roundBestScore, match.score)
|
|
bestScore = Math.max(bestScore, match.score)
|
|
}
|
|
|
|
if (rawHits.length >= limit * 3) {
|
|
hitTargetReached = true
|
|
await reportProgress(reporter, {
|
|
stage: 'streaming_hits',
|
|
message: `Collected ${rawHits.length} candidate hits.`,
|
|
sessionsScanned,
|
|
messagesScanned
|
|
})
|
|
if (roundBestScore === Number.NEGATIVE_INFINITY || roundBestScore <= bestScoreBeforeBatch) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!result.hasMore) break
|
|
}
|
|
|
|
if (truncated && rawHits.length >= limit * 3) {
|
|
break
|
|
}
|
|
|
|
if (messagesScanned >= MAX_SCAN_GLOBAL) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
rawHits.sort((a, b) => b.score - a.score || compareMessageCursorDesc(a.message, b.message))
|
|
|
|
const hits = await Promise.all(rawHits.slice(0, limit).map(async (hit): Promise<McpSearchHit> => ({
|
|
session: hit.session,
|
|
message: await normalizeMessage(hit.session.sessionId, hit.message, {
|
|
includeMediaPaths,
|
|
includeRaw
|
|
}),
|
|
excerpt: hit.excerpt,
|
|
matchedField: hit.matchedField,
|
|
score: hit.score
|
|
})))
|
|
const sessionSummaries = buildSearchSessionSummaries(hits)
|
|
await reportPartial(reporter, 'search_messages', {
|
|
hits,
|
|
limit,
|
|
sessionsScanned,
|
|
messagesScanned,
|
|
truncated,
|
|
sessionSummaries
|
|
})
|
|
|
|
return {
|
|
hits,
|
|
limit,
|
|
sessionsScanned,
|
|
messagesScanned,
|
|
truncated,
|
|
sessionSummaries
|
|
}
|
|
}
|
|
|
|
async getSessionContext(rawArgs: GetSessionContextArgs, defaultIncludeMediaPaths: boolean, reporter?: McpStreamReporter): Promise<McpSessionContextPayload> {
|
|
const args = getSessionContextArgsSchema.safeParse(rawArgs)
|
|
if (!args.success) {
|
|
throw new McpToolError('BAD_REQUEST', 'Invalid get_session_context arguments.', args.error.message)
|
|
}
|
|
|
|
const [{ items: sessions, map: sessionMap }, { items: contacts, map: contactMap }] = await Promise.all([
|
|
getSessionCatalog(),
|
|
getContactCatalog()
|
|
])
|
|
const session = await resolveSessionRefStrictWithProgress(args.data.sessionId, sessions, sessionMap, contacts, contactMap, reporter)
|
|
const resolvedSessionId = session.sessionId
|
|
const includeRaw = args.data.includeRaw ?? false
|
|
const includeMediaPaths = args.data.includeMediaPaths ?? defaultIncludeMediaPaths
|
|
|
|
if (args.data.mode === 'latest') {
|
|
const latestLimit = Math.min(args.data.beforeLimit ?? 30, MAX_CONTEXT_LIMIT)
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Loading latest context for ${session.displayName}.`,
|
|
sessionsScanned: 1
|
|
})
|
|
const result = await chatService.getMessages(resolvedSessionId, 0, latestLimit)
|
|
if (!result.success) {
|
|
mapChatError(result.error)
|
|
}
|
|
|
|
const messages = await normalizeMessages(resolvedSessionId, result.messages || [], {
|
|
includeMediaPaths,
|
|
includeRaw
|
|
})
|
|
await reportPartial(reporter, 'get_session_context', {
|
|
session,
|
|
mode: 'latest',
|
|
items: messages,
|
|
hasMoreBefore: Boolean(result.hasMore),
|
|
hasMoreAfter: false
|
|
})
|
|
|
|
return {
|
|
session,
|
|
mode: 'latest',
|
|
items: messages,
|
|
hasMoreBefore: Boolean(result.hasMore),
|
|
hasMoreAfter: false
|
|
}
|
|
}
|
|
|
|
const anchorCursor = args.data.anchorCursor!
|
|
const beforeLimit = Math.min(args.data.beforeLimit ?? 20, MAX_CONTEXT_LIMIT)
|
|
const afterLimit = Math.min(args.data.afterLimit ?? 20, MAX_CONTEXT_LIMIT)
|
|
await reportProgress(reporter, {
|
|
stage: 'scanning_messages',
|
|
message: `Loading context around anchor in ${session.displayName}.`,
|
|
sessionsScanned: 1
|
|
})
|
|
|
|
const [beforeResult, anchorResult, afterResult] = await Promise.all([
|
|
chatService.getMessagesBefore(
|
|
resolvedSessionId,
|
|
anchorCursor.sortSeq,
|
|
beforeLimit,
|
|
anchorCursor.createTime,
|
|
anchorCursor.localId
|
|
),
|
|
chatService.getMessagesAfter(
|
|
resolvedSessionId,
|
|
anchorCursor.sortSeq,
|
|
1,
|
|
anchorCursor.createTime,
|
|
anchorCursor.localId - 1
|
|
),
|
|
chatService.getMessagesAfter(
|
|
resolvedSessionId,
|
|
anchorCursor.sortSeq,
|
|
afterLimit,
|
|
anchorCursor.createTime,
|
|
anchorCursor.localId
|
|
)
|
|
])
|
|
|
|
if (!beforeResult.success) mapChatError(beforeResult.error)
|
|
if (!anchorResult.success) mapChatError(anchorResult.error)
|
|
if (!afterResult.success) mapChatError(afterResult.error)
|
|
|
|
const anchorMessage = (anchorResult.messages || []).find((message) => sameCursor(message, anchorCursor))
|
|
if (!anchorMessage) {
|
|
throw new McpToolError('BAD_REQUEST', 'Anchor cursor was not found in this session.')
|
|
}
|
|
|
|
const [beforeItems, anchorItem, afterItems] = await Promise.all([
|
|
normalizeMessages(resolvedSessionId, beforeResult.messages || [], {
|
|
includeMediaPaths,
|
|
includeRaw
|
|
}),
|
|
normalizeMessage(resolvedSessionId, anchorMessage, {
|
|
includeMediaPaths,
|
|
includeRaw
|
|
}),
|
|
normalizeMessages(resolvedSessionId, afterResult.messages || [], {
|
|
includeMediaPaths,
|
|
includeRaw
|
|
})
|
|
])
|
|
await reportPartial(reporter, 'get_session_context', {
|
|
session,
|
|
mode: 'around',
|
|
anchor: anchorItem,
|
|
items: [...beforeItems, anchorItem, ...afterItems],
|
|
hasMoreBefore: Boolean(beforeResult.hasMore),
|
|
hasMoreAfter: Boolean(afterResult.hasMore)
|
|
})
|
|
|
|
return {
|
|
session,
|
|
mode: 'around',
|
|
anchor: anchorItem,
|
|
items: [...beforeItems, anchorItem, ...afterItems],
|
|
hasMoreBefore: Boolean(beforeResult.hasMore),
|
|
hasMoreAfter: Boolean(afterResult.hasMore)
|
|
}
|
|
}
|
|
}
|