import * as chat from '../services/chat-service' import type { KnowledgeAttachmentMetadata, KnowledgeEvidence, KnowledgeMessageKind, KnowledgeRuntimeStatus, KnowledgeSearchRequest, KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult, KnowledgeSearchResult, KnowledgeSourceMessage } from '../../shared/knowledge' import type { VoiceMessageReference, VoiceTranscriptSnapshot, VoiceTranscriptUpdate } from '../../shared/voice-recognition' import { DEFAULT_KNOWLEDGE_CHUNKER, DEFAULT_KNOWLEDGE_FTS_CONFIG, emptyKnowledgeSearchTimings } from '../../shared/knowledge' import { KnowledgeService } from './knowledge-service' import { voiceAccountIdentity, voiceMessageIdentity } from '../voice-pipeline/voice-message-identity' const FALLBACK_LIMIT = 240 const MAX_SENDER_NAME_CONVERSATIONS = 8 const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700 type PendingVoiceTranscriptIndex = { update: VoiceTranscriptUpdate waiters: Array<{ resolve: () => void reject: (error: unknown) => void }> } function looksLikeOpaqueSenderId(value: string | undefined): boolean { const normalized = value?.trim() || '' return ( normalized.startsWith('wxid_') || normalized.endsWith('@chatroom') || /^\d{6,}$/.test(normalized) ) } function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]): string { return ( [member.groupNickname, member.wechatNickname, member.nickname, member.remark] .map((value) => value.trim()) .find((value) => value && !looksLikeOpaqueSenderId(value)) || '' ) } function sourceMessageId(message: chat.FormattedMessage): string { if (message.localId) return `local:${message.localId}` if (message.id) return String(message.id) return `${message.createTime || 0}:${message.serverId || message.content}` } function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind { if (message.voiceTranscript || message.type === '语音') return 'voice' if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') { return message.contentData.type === 'share' && message.contentData.typeVal === '6' ? 'file' : 'link' } if (message.contentData?.type === 'system') return 'system' return message.content?.trim() ? 'text' : 'other' } function sourceTextAndAttachment(message: chat.FormattedMessage): { text?: string attachment?: KnowledgeAttachmentMetadata } { const text = message.content?.trim() || '' const content = message.contentData if (!content) { return { text: text || undefined, attachment: message.exportMediaName ? { name: message.exportMediaName, kind: message.exportMediaType === 'file' ? 'file' : 'other' } : undefined } } if (content.type === 'share') { const title = content.title?.trim() || '' const description = content.des?.trim() || '' return { text: [text, title, description].filter(Boolean).join('\n') || undefined, attachment: title || content.url ? { name: title || content.url, kind: content.typeVal === '6' ? 'file' : 'link', url: content.url } : undefined } } if (content.type === 'miniProgram') { return { text: [text, content.title, content.description].filter(Boolean).join('\n') || undefined, attachment: content.title ? { name: content.title, kind: 'link' } : undefined } } if (content.type === 'quote') { return { text: [text, content.title, content.content, content.quotedContent].filter(Boolean).join('\n') || undefined } } if (content.type === 'forwardBundle') { return { text: [text, content.title, content.description, ...content.items.map((item) => item.text)] .filter(Boolean) .join('\n') } } return { text: text || undefined } } function toSourceMessage( accountId: string, conversationId: string, message: chat.FormattedMessage, transcriptOverride?: string ): KnowledgeSourceMessage | null { if (!message.createTime) return null const extracted = sourceTextAndAttachment(message) const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined if (!extracted.text && !extracted.attachment && !voiceTranscript) return null return { accountId, conversationId, messageId: sourceMessageId(message), // Existing chat messages use Unix seconds; the knowledge contract uses milliseconds. createTime: message.createTime * 1000, senderId: message.senderId || message.from || undefined, senderName: message.isSender ? '我' : message.name || undefined, kind: sourceKind(message), text: extracted.text, attachment: extracted.attachment, voiceTranscript } } function normalizeComparable(value: string): string { return value.toLocaleLowerCase().replace(/\s+/g, '') } function fallbackTermScore(message: chat.FormattedMessage, terms: string[]): number { const source = toSourceMessage('fallback', 'fallback', message) const text = `${source?.text || ''}\n${source?.voiceTranscript || ''}\n${source?.attachment?.name || ''}` const normalized = normalizeComparable(text) return terms.reduce((score, term) => { const normalizedTerm = normalizeComparable(term) return normalizedTerm && normalized.includes(normalizedTerm) ? score + normalizedTerm.length : score }, 0) } /** * Main-process adapter for the read-only chat archive. It never passes source * database handles or keys to the worker; only normalized serializable values. */ export class KnowledgeSearchService { private readonly service: KnowledgeService private readonly indexing = new Map>() private readonly statusByAccount = new Map() private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>() private wcdbReadTail: Promise = Promise.resolve() private voiceTranscriptResolver: | ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot) | undefined private voiceIndexTail: Promise = Promise.resolve() private voiceIndexFlushScheduled = false private readonly pendingVoiceIndexes = new Map() constructor(userDataPath: string, workerPath: string) { this.service = new KnowledgeService(userDataPath, workerPath) } startCurrentAccountIndex(): KnowledgeRuntimeStatus { const accountId = this.currentAccountId() if (!accountId) return this.emptyStatus('') const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) if (this.indexing.has(accountId)) return current const started: KnowledgeRuntimeStatus = { ...current, state: current.indexedMessageCount ? 'syncing' : 'building', processedMessages: 0, totalMessages: current.sourceMessageCount, estimatedRemainingMs: null, lastError: undefined } this.publishStatus(started) const task = this.indexAccount(accountId) .catch((error) => { const previous = this.statusByAccount.get(accountId) this.publishStatus({ ...(previous || this.emptyStatus(accountId)), state: 'error', lastError: error instanceof Error ? error.message : String(error) }) throw error }) .finally(() => { this.indexing.delete(accountId) void this.refreshStatus(accountId).catch(() => undefined) }) this.indexing.set(accountId, task) void task.catch((error) => { console.warn('[Knowledge] background index failed:', error) }) return started } /** * The voice cache remains owned by the voice pipeline. Knowledge only reads * a current-account snapshot while constructing a derived local index. */ setVoiceTranscriptResolver( resolver: (reference: VoiceMessageReference) => VoiceTranscriptSnapshot ): void { this.voiceTranscriptResolver = resolver } /** * A successful recognition updates its source conversation. Consecutive * updates for the same conversation are coalesced because a complete * snapshot already includes every finished transcript for that conversation. */ indexVoiceTranscript(update: VoiceTranscriptUpdate): Promise { const key = this.voiceIndexKey(update) return new Promise((resolve, reject) => { const existing = this.pendingVoiceIndexes.get(key) if (existing) { existing.update = update existing.waiters.push({ resolve, reject }) } else { this.pendingVoiceIndexes.set(key, { update, waiters: [{ resolve, reject }] }) } this.scheduleVoiceIndexFlush() }) } private voiceIndexKey(update: VoiceTranscriptUpdate): string { return `${update.accountIdentity}:${update.reference.sessionId}` } private scheduleVoiceIndexFlush(): void { if (this.voiceIndexFlushScheduled) return this.voiceIndexFlushScheduled = true const task = this.voiceIndexTail.then(() => this.flushPendingVoiceIndexes()) this.voiceIndexTail = task.catch(() => undefined) void task.then( () => this.finishVoiceIndexFlush(), () => this.finishVoiceIndexFlush() ) } private async flushPendingVoiceIndexes(): Promise { while (this.pendingVoiceIndexes.size) { const pending = Array.from(this.pendingVoiceIndexes.values()) this.pendingVoiceIndexes.clear() for (const entry of pending) { try { await this.indexVoiceTranscriptNow(entry.update) entry.waiters.forEach((waiter) => waiter.resolve()) } catch (error) { entry.waiters.forEach((waiter) => waiter.reject(error)) } } } } private finishVoiceIndexFlush(): void { this.voiceIndexFlushScheduled = false if (this.pendingVoiceIndexes.size) this.scheduleVoiceIndexFlush() } async search(request: KnowledgeSearchIpcRequest): Promise { const accountId = this.currentAccountId() if (!accountId) return this.searchFallback(request, 'unavailable') try { const searchRequest: Omit = { accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG, text: request.text, terms: request.terms, limit: Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)), conversationIds: request.conversationIds, senderIds: request.senderIds, startTime: request.startTime === undefined ? undefined : request.startTime * 1000, endTime: request.endTime === undefined ? undefined : request.endTime * 1000 } const result = await this.searchKnowledge(searchRequest) // An existing derived database can answer while its next incremental pass is running. // Never turn an interactive global search into another full WCDB scan during that pass. if (result.state === 'ready' || result.evidence.length) { return this.toKnowledgeResult(result) } if (this.indexing.has(accountId)) { return { ...result, source: 'knowledge', totalMessages: result.indexedMessageCount } } return this.searchFallback(request, 'unavailable') } catch (error) { console.warn('[Knowledge] search failed, using legacy fallback:', error) return this.searchFallback(request, 'error') } } async dispose(): Promise { await this.service.dispose() } /** Safely release derived SQLite handles before the cache screen removes them. */ async prepareForCacheClear(): Promise { if (this.indexing.size) { throw new Error('本地知识库正在同步,请等待同步完成后再清理') } await this.service.dispose() const accountIds = Array.from(this.statusByAccount.keys()) this.statusByAccount.clear() accountIds.forEach((accountId) => this.publishStatus(this.emptyStatus(accountId))) } onStatusChange(listener: (status: KnowledgeRuntimeStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) } async getStatus(): Promise { const accountId = this.currentAccountId() if (!accountId) return this.emptyStatus('') return this.refreshStatus(accountId) } private currentAccountId(): string { if (!chat.isReady()) return '' return chat.getSelfAccountInfo()?.wxid || chat.getCurrentAccountRoot() } private async indexAccount(accountId: string): Promise { const contacts = await this.listContacts() let processedMessages = 0 const startedAt = Date.now() this.publishStatus({ ...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)), state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building', processedMessages: 0, totalMessages: null, estimatedRemainingMs: null }) for (const [index, contact] of contacts.entries()) { // WCDB rejects overlapping async pagination. Queue every archive read so // background indexing and an interactive fallback search can interleave safely. const messages = await this.listMessages(contact.md5) const sourceMessages = messages .map((message) => this.toSourceMessage(accountId, contact.md5, message)) .filter((message): message is KnowledgeSourceMessage => Boolean(message)) await this.service.index( { accountId, conversations: [ { conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages } ], chunker: DEFAULT_KNOWLEDGE_CHUNKER, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG, sourceMessageCount: index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined }, (progress) => { const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) this.publishStatus({ ...current, state: current.indexedMessageCount ? 'syncing' : 'building', processedMessages: processedMessages + progress.processedMessages, totalMessages: null, currentConversationId: progress.conversationId, estimatedRemainingMs: null }) } ) processedMessages += sourceMessages.length const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId) this.publishStatus({ ...current, state: current.indexedMessageCount ? 'syncing' : 'building', processedMessages, totalMessages: null, currentConversationId: contact.md5, estimatedRemainingMs: null }) } await this.refreshStatus(accountId, { processedMessages, totalMessages: processedMessages, startedAt }) } private async searchFallback( request: KnowledgeSearchIpcRequest, fallbackReason: 'unavailable' | 'indexing' | 'error' ): Promise { const startedAt = Date.now() const contacts = await this.listContacts() const allowedConversations = new Set(request.conversationIds || []) const sourceContacts = allowedConversations.size ? contacts.filter((contact) => allowedConversations.has(contact.md5)) : contacts const senderIds = new Set(request.senderIds || []) const terms = request.terms.filter((term) => term.trim().length >= 2) const matches: Array<{ contact: (typeof sourceContacts)[number] message: chat.FormattedMessage score: number }> = [] let totalMessages = 0 for (const contact of sourceContacts) { const messages = await this.listMessages(contact.md5, request.startTime, request.endTime) totalMessages += messages.length for (const message of messages) { const hydrated = this.withVoiceTranscript(message) matches.push({ contact, message: hydrated, score: fallbackTermScore(hydrated, terms) }) } } const filtered = matches .filter(({ message, score }) => { const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from) const termMatches = !terms.length || score > 0 return senderMatches && termMatches }) .sort( (left, right) => right.score - left.score || (right.message.createTime || 0) - (left.message.createTime || 0) ) .slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT))) const result: KnowledgeSearchIpcResult = { source: 'fallback', fallbackReason, state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, totalMessages, timings: { ...emptyKnowledgeSearchTimings(), messageLoadMs: Date.now() - startedAt, totalMs: Date.now() - startedAt }, evidence: filtered.map(({ contact, message, score }) => ({ chunkId: `fallback:${contact.md5}:${sourceMessageId(message)}`, conversationId: contact.md5, startTime: (message.createTime || 0) * 1000, endTime: (message.createTime || 0) * 1000, messageId: sourceMessageId(message), senderId: message.senderId || message.from || undefined, sender: message.isSender ? '我' : message.name || '未知成员', timestamp: (message.createTime || 0) * 1000, messageIds: [sourceMessageId(message)], sourceKind: sourceKind(message), text: this.toSourceMessage('fallback', contact.md5, message)?.voiceTranscript || sourceTextAndAttachment(message).text || message.content || `[${message.type}]`, score: -score })) } return { ...result, evidence: await this.enrichEvidenceSenders(result.evidence) } } /** * SQLite has a finite bind-parameter limit. Group/one-to-one scope filters * can contain over one thousand conversations, so split only the Worker * query and merge real Evidence instead of dropping the selected scope. */ private async searchKnowledge( request: Omit ): Promise { const conversationIds = Array.from(new Set(request.conversationIds || [])) if (conversationIds.length <= MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH) { return this.searchWorker(request) } const partialResults: KnowledgeSearchResult[] = [] for ( let start = 0; start < conversationIds.length; start += MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH ) { partialResults.push( await this.searchWorker({ ...request, conversationIds: conversationIds.slice( start, start + MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH ) }) ) } const evidenceByIdentity = new Map() partialResults .flatMap((result) => result.evidence) .forEach((item) => { const identity = `${item.conversationId}:${item.messageId}` const existing = evidenceByIdentity.get(identity) if (!existing || (item.score || 0) < (existing.score || 0)) { evidenceByIdentity.set(identity, item) } }) const mergeStartedAt = Date.now() const mergedEvidence = Array.from(evidenceByIdentity.values()) .sort( (left, right) => (left.score || 0) - (right.score || 0) || right.timestamp - left.timestamp ) .slice(0, request.limit) const timings = partialResults.reduce( (total, result) => ({ workerIpcMs: total.workerIpcMs + (result.timings?.workerIpcMs || 0), workerBootMs: total.workerBootMs + (result.timings?.workerBootMs || 0), dispatchMs: total.dispatchMs + (result.timings?.dispatchMs || 0), workerSqlMs: total.workerSqlMs + (result.timings?.workerSqlMs || 0), responseTransferMs: total.responseTransferMs + (result.timings?.responseTransferMs || 0), responseSerializeMs: total.responseSerializeMs + (result.timings?.responseSerializeMs || 0), ftsMs: total.ftsMs + (result.timings?.ftsMs || 0), messageLoadMs: total.messageLoadMs + (result.timings?.messageLoadMs || 0), chunkExpandMs: total.chunkExpandMs + (result.timings?.chunkExpandMs || 0), rankingMs: total.rankingMs + (result.timings?.rankingMs || 0), totalMs: total.totalMs + (result.timings?.totalMs || 0) }), emptyKnowledgeSearchTimings() ) const mergeRankingMs = Date.now() - mergeStartedAt timings.rankingMs += mergeRankingMs timings.totalMs += mergeRankingMs const voiceCoverageParts = partialResults .map((result) => result.voiceCoverage) .filter((coverage): coverage is NonNullable => Boolean(coverage)) const voiceCoverage = voiceCoverageParts.length ? voiceCoverageParts.reduce( (total, coverage) => ({ voiceMessageCount: total.voiceMessageCount + coverage.voiceMessageCount, transcribedVoiceCount: total.transcribedVoiceCount + coverage.transcribedVoiceCount, failedVoiceCount: total.failedVoiceCount + coverage.failedVoiceCount, voiceCoverageComplete: false }), { voiceMessageCount: 0, transcribedVoiceCount: 0, failedVoiceCount: 0, voiceCoverageComplete: false } ) : undefined if (voiceCoverage) { voiceCoverage.voiceCoverageComplete = voiceCoverage.voiceMessageCount === voiceCoverage.transcribedVoiceCount } return { state: partialResults.some((result) => result.state === 'ready') ? 'ready' : partialResults.some((result) => result.state === 'indexing') ? 'indexing' : 'unavailable', indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)), indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)), evidence: mergedEvidence, timings, voiceCoverage } } private async searchWorker( request: Omit ): Promise { const startedAt = Date.now() const result = await this.service.search(request) const timings = result.timings || emptyKnowledgeSearchTimings() return { ...result, timings: { ...timings, workerIpcMs: timings.workerIpcMs || Math.max(0, Date.now() - startedAt - timings.totalMs), workerSqlMs: timings.workerSqlMs || timings.totalMs } } } private listContacts(): ReturnType { return this.enqueueWcdbRead(() => chat.listContactsAsync()) } private listMessages( conversationId: string, startTime?: number, endTime?: number ): ReturnType { return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime)) } private withVoiceTranscript(message: chat.FormattedMessage): chat.FormattedMessage { const reference = this.voiceReferenceFromMessage(message) if (!reference || !this.voiceTranscriptResolver) return message const snapshot = this.voiceTranscriptResolver(reference) if (snapshot.state !== 'transcribed' || !snapshot.transcript?.trim()) return message return { ...message, voiceTranscript: snapshot.transcript.trim() } } private toSourceMessage( accountId: string, conversationId: string, message: chat.FormattedMessage, transcriptOverride?: string, stateOverride?: 'pending' | 'transcribed' | 'failed' ): KnowledgeSourceMessage | null { const reference = this.voiceReferenceFromMessage(message) const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined const hydrated = this.withVoiceTranscript(message) const source = toSourceMessage( accountId, conversationId, hydrated, transcriptOverride ) if (!source || source.kind !== 'voice') return source return { ...source, voiceTranscriptState: stateOverride || (transcriptOverride?.trim() ? 'transcribed' : undefined) || snapshot?.state || (source.voiceTranscript ? 'transcribed' : 'pending') } } private voiceReferenceFromMessage( message: chat.FormattedMessage ): VoiceMessageReference | undefined { if (message.type !== '语音' || !message.sessionId || message.localId === undefined || !message.createTime) { return undefined } return { sessionId: message.sessionId, localId: message.localId, createTime: message.createTime, svrId: message.serverId } } private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise { if (!chat.isReady()) return if (update.state === 'transcribed' && !update.transcript?.trim()) return if (voiceAccountIdentity(chat.getCurrentAccountRoot()) !== update.accountIdentity) { return } const accountId = this.currentAccountId() if (!accountId) return const activeIndex = this.indexing.get(accountId) if (activeIndex) await activeIndex if (voiceAccountIdentity(chat.getCurrentAccountRoot()) !== update.accountIdentity) { return } const contacts = await this.listContacts() const contact = contacts.find((item) => item.m_nsUsrName === update.reference.sessionId) if (!contact) return const messages = await this.listMessages(contact.md5) const sourceMessages = messages .map((message) => { const reference = this.voiceReferenceFromMessage(message) const transcriptOverride = reference && voiceMessageIdentity(reference) === update.messageIdentity ? update.transcript : undefined const stateOverride = reference && voiceMessageIdentity(reference) === update.messageIdentity ? update.state : undefined return this.toSourceMessage( accountId, contact.md5, message, transcriptOverride, stateOverride ) }) .filter((message): message is KnowledgeSourceMessage => Boolean(message)) await this.service.index({ accountId, conversations: [ { conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages } ], chunker: DEFAULT_KNOWLEDGE_CHUNKER, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG }) await this.refreshStatus(accountId) } private async toKnowledgeResult( result: KnowledgeSearchResult ): Promise { return { ...result, evidence: await this.enrichEvidenceSenders(result.evidence), source: 'knowledge', totalMessages: result.indexedMessageCount } } private async enrichEvidenceSenders(evidence: KnowledgeEvidence[]): Promise { const candidateConversationIds = Array.from( new Set( evidence .filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender)) .map((item) => item.conversationId) ) ).slice(0, MAX_SENDER_NAME_CONVERSATIONS) if (!candidateConversationIds.length) return evidence const contacts = await this.listContacts() const groupConversationIds = new Set( contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5) ) const memberNamesByConversation = new Map>() for (const conversationId of candidateConversationIds) { if (!groupConversationIds.has(conversationId)) continue const snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId)) const memberNames = new Map( (snapshot?.members || []) .map((member) => [member.wxid, groupMemberDisplayName(member)] as const) .filter(([, name]) => Boolean(name)) ) if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames) } return evidence.map((item) => { const sender = memberNamesByConversation.get(item.conversationId)?.get(item.senderId || '') return sender ? { ...item, sender } : item }) } private enqueueWcdbRead(operation: () => Promise): Promise { const result = this.wcdbReadTail.then(operation, operation) // Keep the queue usable after a read failure while returning that failure to its caller. this.wcdbReadTail = result.then( () => undefined, () => undefined ) return result } private emptyStatus(accountId: string): KnowledgeRuntimeStatus { return { accountId, state: 'unavailable', indexedMessageCount: 0, indexedChunkCount: 0, sourceMessageCount: null, processedMessages: 0, totalMessages: null, estimatedRemainingMs: null, databaseBytes: 0, walBytes: 0, shmBytes: 0 } } private async refreshStatus( accountId: string, progress?: Pick & { startedAt?: number } ): Promise { const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG }) const current = this.statusByAccount.get(accountId) const indexing = this.indexing.has(accountId) const processedMessages = progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount const state = indexing ? remote.indexedMessageCount > 0 ? 'syncing' : 'building' : remote.state const status: KnowledgeRuntimeStatus = { ...remote, state, processedMessages, totalMessages, estimatedRemainingMs: null } this.publishStatus(status) return status } private publishStatus(status: KnowledgeRuntimeStatus): void { this.statusByAccount.set(status.accountId, status) for (const listener of this.statusListeners) listener(status) } }