test: 暂存代码

This commit is contained in:
电摇小子
2026-08-06 20:29:25 +08:00
parent 307d247660
commit ad4b3a8074
43 changed files with 9978 additions and 512 deletions
+625
View File
@@ -0,0 +1,625 @@
import type { KnowledgeEvidence, KnowledgeSearchIpcResult } from './knowledge'
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
export type AiSearchRange = 'today' | '7d' | '30d' | 'all'
/**
* Retrieval semantics, not presentation labels. Each intent has a constrained
* execution path in the main process; a model must not be able to quietly turn
* an identity lookup into a generic message-keyword search.
*/
export type AiSearchIntent =
| 'conversation_recall'
| 'conversation_topic_search'
| 'global_topic_search'
| 'conversation_name_search'
| 'general'
export interface AiSearchTimeRange {
/** Unix seconds. Undefined start means the user explicitly allowed all history. */
startTime?: number
endTime?: number
label: string
reason: string
source: 'ui' | 'query' | 'user_retry'
}
export type AiSearchProgressStage =
| 'query_understanding'
| 'agent_start'
| 'agent_tool'
| 'agent_decision'
| 'search_plan_ready'
| 'knowledge_searching'
| 'evidence_ranking'
| 'evidence_ready'
| 'aggregation'
| 'ai_generating'
| 'completed'
| 'error'
export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
export interface AiSearchPlan {
intent: AiSearchIntent
keywords: string[]
variants: string[]
source: 'local' | 'ai' | 'hybrid'
scopeLabel: string
rangeLabel: string
timeRange: AiSearchTimeRange
contactNames: string[]
/** A user-supplied identity candidate. It must be resolved by Contact Resolution. */
contactQuery?: string
/** The message-content query, never a contact display name. */
topicQuery?: string
}
export interface AiSearchPipelineRequest {
requestId: string
text: string
scope: AiSearchScope
range: AiSearchRange
conversationId?: string
/** Explicit user retry takes precedence over natural-language inference. */
timeRangeOverride?: AiSearchTimeRange
}
export interface AiSearchProgressEvent {
requestId: string
stage: AiSearchProgressStage
status: AiSearchProgressStatus
message: string
plan?: AiSearchPlan
stats?: {
knowledgeMessageCount?: number
matchedMessages?: number
evidenceCount?: number
contextEvidenceCount?: number
tokenEstimate?: number
inputTokens?: number
inputTokensEstimated?: boolean
elapsedMs?: number
deduplicatedMessages?: number
peopleCount?: number
conversationCount?: number
}
timings?: AiSearchPipelineTimings
modelName?: string
agentTrace?: AiSearchAgentTraceItem
error?: string
}
export type AiSearchAgentToolName =
| 'search_conversations'
| 'search_people'
| 'search_messages'
| 'get_conversation_messages'
| 'get_messages_by_time'
| 'get_message_context'
export type AiSearchAgentTraceEvent =
| 'agentStart'
| 'toolCallStart'
| 'toolCallEnd'
| 'agentDecision'
| 'evidenceBuild'
| 'summaryStart'
| 'summaryEnd'
| 'fallback'
/** Public trace: deliberately contains no SQL, paths, raw IDs, or Worker details. */
export interface AiSearchAgentTraceItem {
sequence: number
event: AiSearchAgentTraceEvent
label: string
toolName?: AiSearchAgentToolName
/** Sanitized, human-readable arguments only. */
arguments?: Record<string, string | number | boolean>
resultCount?: number
elapsedMs?: number
decision?: string
/** Bounded local snapshot of the exact decision prompt; never sent to analytics. */
decisionInput?: string
}
export interface AiSearchAgentRun {
mode: 'agent' | 'fallback'
toolCalls: number
trace: AiSearchAgentTraceItem[]
fallbackReason?: string
}
export interface AiSearchPipelineEvidence extends KnowledgeEvidence {
conversationName: string
conversationType: 'user' | 'group'
}
/** A program-generated, stable citation. This is the only Evidence shape sent to AI/UI. */
export interface AiSearchFinalEvidence extends AiSearchPipelineEvidence {
id: `E${number}`
}
export interface AiSearchPersonAggregation {
id: string
name: string
messageCount: number
conversationCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchConversationAggregation {
id: string
name: string
type: 'user' | 'group'
messageCount: number
peopleCount: number
lastMessageAt: number
evidenceIds: Array<`E${number}`>
}
export interface AiSearchAggregation {
messageCount: number
peopleCount: number
conversationCount: number
people: AiSearchPersonAggregation[]
conversations: AiSearchConversationAggregation[]
}
/** All fields are directly measured around real work. */
export interface AiSearchPipelineTimings {
queryUnderstandingMs: number
contactResolutionMs: number
knowledgeSearchMs: number
workerIpcMs: number
workerBootMs: number
dispatchMs: number
workerSqlMs: number
responseSerializeMs: number
responseTransferMs: number
ftsMs: number
chunkExpandMs: number
messageLoadMs: number
rankingMs: number
candidateRankingMs: number
evidenceBuildMs: number
aggregationMs: number
contextPreparationMs: number
agentDecisionMs: number
agentToolMs: number
aiGenerationMs: number
totalMs: number
}
export interface AiSearchCitationValidation {
status: 'valid' | 'sanitized'
invalidCitationIds: string[]
}
/** Truthful retrieval metadata shared by the AI, UI and diagnostics. */
export interface AiSearchRetrievalContract {
intent: AiSearchIntent
conversationId?: string
timeRange: AiSearchTimeRange
retrievalMode:
| 'conversation_metadata'
| 'conversation_topic_fts'
| 'global_fts'
| 'conversation_name'
| 'unresolved_identity'
candidateCount: number
sourceMessageCount?: number
sourceCoverage: 'complete' | 'partial' | 'keyword_match' | 'unknown'
isComplete: boolean
fallbackUsed: boolean
fallbackReason?: string
suspicious: boolean
}
export interface AiSearchPipelineResult {
requestId: string
status: 'completed' | 'no_evidence' | 'retrieval_incomplete' | 'ai_failed' | 'failed'
plan: AiSearchPlan
knowledge: Pick<
KnowledgeSearchIpcResult,
| 'source'
| 'state'
| 'fallbackReason'
| 'indexedMessageCount'
| 'indexedChunkCount'
| 'totalMessages'
>
candidateEvidenceCount: number
retrieval: AiSearchRetrievalContract
evidence: AiSearchFinalEvidence[]
contextEvidenceCount: number
aggregation: AiSearchAggregation
agent: AiSearchAgentRun
citationValidation?: AiSearchCitationValidation
timings: AiSearchPipelineTimings
answer?: string
ai?: {
providerName: string
modelName: string
inputTokens?: number
inputTokensEstimated: boolean
}
error?: string
errorStage?: Exclude<AiSearchProgressStage, 'completed' | 'error'>
elapsedMs: number
}
const RANGE_LABELS: Record<AiSearchRange, string> = {
today: '今天',
'7d': '近 7 天',
'30d': '近 30 天',
all: '全部历史'
}
const SEARCH_INTENT_PHRASES = [
'全局搜一下',
'全局搜索',
'搜索一下',
'搜一下',
'查询一下',
'查一下',
'找一下',
'我和谁聊过',
'谁和我聊过',
'谁聊过',
'哪些人和我聊过',
'最近讨论了什么',
'最近聊了什么',
'最近说了什么',
'讨论了什么',
'讨论什么',
'聊了什么',
'聊些什么',
'说了什么',
'说些什么',
'最近讨论',
'最近聊天',
'这个话题',
'相关话题',
'的聊天',
'的内容',
'的记录',
'关于',
'聊天',
'记录',
'聊天记录',
'帮我',
'请问',
'最近'
].sort((left, right) => right.length - left.length)
const SEARCH_STOP_WORDS = new Set([
'我',
'谁',
'什么',
'哪些',
'哪个',
'人',
'和',
'聊过',
'说过',
'提到',
'讨论',
'聊天',
'记录',
'说',
'聊',
'话题',
'内容',
'相关',
'最近',
'一下'
])
export const aiSearchRangeLabel = (range: AiSearchRange): string => RANGE_LABELS[range]
export const aiSearchRangeStart = (range: AiSearchRange): number | undefined => {
if (range === 'all') return undefined
if (range === 'today') {
const now = new Date()
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
}
return Math.floor(Date.now() / 1000) - (range === '7d' ? 7 : 30) * 86400
}
const dayStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000)
const currentYearStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000)
const currentMonthStart = (date: Date): number =>
Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000)
const CHINESE_NUMBERS: Record<string, number> = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
const parseNaturalNumber = (value: string | undefined): number | undefined => {
if (!value) return undefined
const numeric = Number(value)
if (Number.isFinite(numeric)) return numeric
return CHINESE_NUMBERS[value]
}
/**
* Query time expressions are part of SearchPlan, never a renderer-only rule.
* A natural-language time constraint is more specific than the broad "all" UI scope.
*/
export const inferAiSearchTimeRange = (
query: string,
uiRange: AiSearchRange,
now = new Date(),
override?: AiSearchTimeRange
): AiSearchTimeRange => {
if (override?.source === 'user_retry') return override
const nowSeconds = Math.floor(now.getTime() / 1000)
const fromQuery = (startTime: number, label: string, reason: string): AiSearchTimeRange => ({
startTime,
endTime: nowSeconds,
label,
reason,
source: 'query'
})
const recentDays = query.match(/最近\s*(\d{1,3}|[一二两三四五六七八九十])\s*天/)
if (recentDays) {
const days = Math.max(1, Math.min(365, parseNaturalNumber(recentDays[1]) || 30))
return fromQuery(nowSeconds - days * 86400, `${days}`, `用户说“最近 ${days} 天”`)
}
const recentMonths = query.match(/最近\s*(\d{1,2}|[一二两三四五六七八九十])\s*个?月/)
if (recentMonths) {
const months = Math.max(1, Math.min(24, parseNaturalNumber(recentMonths[1]) || 1))
const start = new Date(now.getFullYear(), now.getMonth() - months, now.getDate()).getTime()
return fromQuery(Math.floor(start / 1000), `${months} 个月`, `用户说“最近 ${months} 个月”`)
}
if (/刚刚|刚才/.test(query))
return fromQuery(nowSeconds - 24 * 3600, '近 24 小时', '用户说“刚刚”')
if (/这几天/.test(query)) return fromQuery(nowSeconds - 7 * 86400, '近 7 天', '用户说“这几天”')
if (/这周|本周/.test(query)) {
const weekday = now.getDay() || 7
return fromQuery(dayStart(now) - (weekday - 1) * 86400, '本周', '用户说“这周”')
}
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
if (/上个月/.test(query)) {
const start = Math.floor(new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000)
const end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
return {
startTime: start,
endTime: end,
label: '上个月',
reason: '用户说“上个月”',
source: 'query'
}
}
if (/今年/.test(query)) return fromQuery(currentYearStart(now), '今年', '用户说“今年”')
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
return {
startTime: aiSearchRangeStart(uiRange),
endTime: undefined,
label: aiSearchRangeLabel(uiRange),
reason: '使用界面选择的时间范围',
source: 'ui'
}
}
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
if (intent === 'conversation_recall') return '回顾最近聊天'
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
if (intent === 'global_topic_search') return '按话题查找'
if (intent === 'conversation_name_search') return '查找聊天'
return '综合查找'
}
export const aiSearchScopeLabel = (scope: AiSearchScope, conversationName?: string): string => {
if (scope === 'groups') return '群聊'
if (scope === 'contacts') return '单聊'
if (scope === 'conversation') return conversationName || '当前会话'
return '所有聊天'
}
const normalizeTerms = (terms: unknown): string[] => {
if (!Array.isArray(terms)) return []
return Array.from(
new Set(
terms
.filter((term): term is string => typeof term === 'string')
.map((term) => term.trim())
.filter((term) => term.length >= 2 && term.length <= 32)
)
).slice(0, 16)
}
const extractKeywords = (query: string): string[] => {
const cleaned = SEARCH_INTENT_PHRASES.reduce(
(value, phrase) => value.split(phrase).join(' '),
query.toLowerCase()
)
return Array.from(
new Set(
cleaned
.split(/[\s,,。!?!?、:;"“”‘’()()[\]【】]+/)
.map((token) => token.trim())
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
)
)
}
const keywordVariants = (keywords: string[]): string[] =>
Array.from(
new Set(
keywords.flatMap((keyword) => {
const variants = [keyword]
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
variants.push(keyword.slice(-2))
}
return variants
})
)
)
export const buildLocalAiSearchPlan = (
query: string
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
const keywords = extractKeywords(query)
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim()
const recall = normalized.match(
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const reverseRecall = normalized.match(
/^\s*(.+?)\s*(?:最近)?(?:跟我|和我|与我)\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const namedConversationRecall = normalized.match(
/(?:我在|在)\s*(.+?)\s*(?:最近)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
)
const conversationTopic = normalized.match(
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月)?\s*(?:聊过|提过|说过|讨论过)\s*(.+?)(?:吗|么|沒有|没有)?[?。!!]*$/
)
const globalTopic = normalized.match(
/(?:最近|这几天|本周|这个月|本月|今年)?\s*(?:谁|哪些人|大家)\s*(?:聊过|提过|说过|讨论过)\s*(.+?)[?。!!]*$/
)
const conversationName =
!recall &&
!reverseRecall &&
!conversationTopic &&
!namedConversationRecall &&
!globalTopic &&
/^[^,。!?!?]{2,32}(?:群|群聊|交流群)$/.test(normalized)
? normalized
: undefined
const contactQuery = (
conversationTopic?.[1] ||
recall?.[1] ||
reverseRecall?.[1] ||
namedConversationRecall?.[1]
)
?.replace(/^(?:和|跟|与)\s*/, '')
.trim()
const topicQuery = (conversationTopic?.[2] || globalTopic?.[1])
?.replace(/^(?:关于|一下|吗|么)\s*/, '')
.trim()
const intent: AiSearchIntent = conversationTopic
? 'conversation_topic_search'
: recall || reverseRecall
? 'conversation_recall'
: namedConversationRecall
? 'conversation_name_search'
: globalTopic
? 'global_topic_search'
: conversationName
? 'conversation_name_search'
: keywords.length
? 'global_topic_search'
: 'general'
const effectiveKeywords = topicQuery ? [topicQuery] : keywords
return {
intent,
keywords: effectiveKeywords,
variants: keywordVariants(effectiveKeywords),
source: 'local',
contactQuery: contactQuery || conversationName,
topicQuery
}
}
export const parseAiSearchPlan = (
value: string
): Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null => {
const jsonMatch = value.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
const intent = [
'general',
'conversation_recall',
'conversation_topic_search',
'global_topic_search',
'conversation_name_search'
].includes(String(parsed.intent))
? (parsed.intent as AiSearchIntent)
: undefined
return {
intent,
keywords: normalizeTerms(parsed.keywords),
variants: normalizeTerms(parsed.variants),
topicQuery:
typeof parsed.topicQuery === 'string' && parsed.topicQuery.trim().length >= 2
? parsed.topicQuery.trim().slice(0, 64)
: undefined
}
} catch {
return null
}
}
export const mergeAiSearchPlans = (
local: Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
>,
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null
): Pick<
AiSearchPlan,
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
> => {
if (!ai) return local
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
const variants = normalizeTerms([
...keywordVariants(keywords),
...local.variants,
...(ai.variants || [])
])
// Identity-bearing local intents are deterministic contracts. A planner may
// refine topic terms but may not weaken them into an unrelated FTS intent.
const lockedIntent =
local.intent === 'conversation_recall' ||
local.intent === 'conversation_topic_search' ||
local.intent === 'conversation_name_search'
return {
intent: lockedIntent ? local.intent : ai.intent || local.intent,
keywords,
variants,
source: 'hybrid',
contactQuery: local.contactQuery,
topicQuery: local.topicQuery || ai.topicQuery
}
}
export const includesExplicitAiSearchAlias = (query: string, alias: string): boolean => {
const name = alias.trim()
if (!name || name.length < 2) return false
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const quoted = new RegExp(`[“"'‘「『]${escaped}[”"'’」』]`)
const relational = new RegExp(
`(?:我和|我跟|我与|和|跟|与|在|给|向|@)${escaped}(?=$|[\\s,。!?!?、::;;])`
)
if (quoted.test(query) || relational.test(query)) return true
// Users often omit a nickname's punctuation, for example typing
// “中田健身弘毅” for “中田健身-弘毅”. Keep this tolerant matching limited
// to an explicit relational query so a short alias cannot accidentally
// select a contact from unrelated prose.
const compact = (value: string): string =>
value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
const compactName = compact(name)
return (
compactName.length >= 2 &&
/(?:我和|我跟|我与|和|跟|与|在|给|向|@)/.test(query) &&
compact(query).includes(compactName)
)
}
+2 -2
View File
@@ -1,7 +1,7 @@
export type CacheClearScope = 'bootstrap' | 'electron' | 'all'
export type CacheClearScope = 'bootstrap' | 'electron' | 'knowledge' | 'all'
export interface CacheSummaryItem {
id: 'bootstrap' | 'electron'
id: 'bootstrap' | 'electron' | 'knowledge'
label: string
description: string
sizeBytes: number
+38
View File
@@ -0,0 +1,38 @@
export type ContactResolutionMatch = 'exact' | 'normalized' | 'alias' | 'fuzzy'
export interface ContactResolutionCandidate {
conversationId: string
displayName: string
matchedBy: ContactResolutionMatch
confidence: number
}
export interface ContactResolutionResult {
matched: boolean
personId?: string
conversationId?: string
canonicalName?: string
displayName?: string
matchedBy?: ContactResolutionMatch
confidence: number
candidates: ContactResolutionCandidate[]
ambiguous: boolean
}
/**
* Identity-only canonicalization. It deliberately does not use substring
* matching: callers may use a separate UI-filter policy for broad discovery,
* but identity resolution must never turn 张三 into 张三丰.
*/
export const normalizeContactName = (value: string): string =>
String(value || '')
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[\p{White_Space}\p{P}\p{S}_]+/gu, '')
export const emptyContactResolution = (): ContactResolutionResult => ({
matched: false,
confidence: 0,
candidates: [],
ambiguous: false
})
+344
View File
@@ -0,0 +1,344 @@
/**
* Contracts for the local, derived knowledge base. These values deliberately
* contain no WCDB handles, Electron objects, database keys, or UI state so the
* indexer can run in an isolated process.
*/
export const KNOWLEDGE_SCHEMA_VERSION = 1
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1'
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'system' | 'other'
export type KnowledgeIndexPhase =
| 'idle'
| 'preflight'
| 'indexing'
| 'ready'
| 'cancelled'
| 'error'
export type KnowledgeTemporalIntent = 'none' | 'current' | 'historical' | 'timeline'
export type KnowledgeFtsTokenizer = 'unicode61' | 'trigram'
export type KnowledgeFtsContentMode = 'external' | 'internal'
export type KnowledgeFtsDetail = 'full' | 'column' | 'none'
export interface KnowledgeAttachmentMetadata {
name: string
kind?: 'file' | 'link' | 'image' | 'video' | 'other'
url?: string
sizeBytes?: number
}
/** A read-only source record prepared by the future WCDB adapter. */
export interface KnowledgeSourceMessage {
accountId: string
conversationId: string
messageId: string
/** Unix epoch milliseconds. Adapters must convert source-specific units. */
createTime: number
senderId?: string
senderName?: string
kind: KnowledgeMessageKind
text?: string
attachment?: KnowledgeAttachmentMetadata
voiceTranscript?: string
}
export interface KnowledgeNormalizedMessage extends KnowledgeSourceMessage {
searchableText: string
contentHash: string
}
export interface KnowledgeChunkerConfig {
version: string
maxGapMs: number
maxMessages: number
maxCharacters: number
overlapMessages: number
}
export interface KnowledgeChunk {
chunkId: string
accountId: string
conversationId: string
startTime: number
endTime: number
text: string
messageIds: string[]
participantIds: string[]
messageKinds: KnowledgeMessageKind[]
contentHash: string
chunkerVersion: string
}
/**
* Every FTS choice is explicit. The first production profile must be selected
* from the Task 0 report rather than being silently hard-coded in the UI.
*/
export interface KnowledgeFtsConfig {
profileId: string
tokenizer: KnowledgeFtsTokenizer
contentMode: KnowledgeFtsContentMode
detail: KnowledgeFtsDetail
columnsize: 0 | 1
}
/**
* Chosen after the realistic desensitized WeChat benchmark: trigram preserves
* Chinese-substring recall while external content avoids a second text copy.
*/
export const DEFAULT_KNOWLEDGE_FTS_CONFIG: KnowledgeFtsConfig = {
profileId: 'trigram-external-full-columnsize-v1',
tokenizer: 'trigram',
contentMode: 'external',
detail: 'full',
columnsize: 1
}
export interface KnowledgeConversationInput {
conversationId: string
/** true means this is a complete read-only snapshot of the conversation. */
completeSnapshot: boolean
messages: KnowledgeSourceMessage[]
}
export interface KnowledgeIndexRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
fts: KnowledgeFtsConfig
/** Written only after a complete source pass; used for truthful coverage. */
sourceMessageCount?: number
}
export interface KnowledgeIndexProgress {
accountId: string
phase: KnowledgeIndexPhase
conversationId?: string
processedMessages: number
totalMessages: number
indexedChunks: number
error?: string
}
export interface KnowledgeIndexResult {
accountId: string
processedMessages: number
indexedChunks: number
updatedChunks: number
unchangedConversations: number
databaseBytes: number
walBytes: number
elapsedMs: number
cancelled: boolean
}
export interface KnowledgeCapacityPreflightRequest {
accountId: string
databaseRoot: string
conversations: KnowledgeConversationInput[]
chunker: KnowledgeChunkerConfig
/** Optional free space supplied by the platform layer; this module never probes WCDB paths. */
availableDiskBytes?: number
}
export interface KnowledgeCapacityPreflight {
accountId: string
sourceMessageCount: number
indexableMessageCount: number
indexableTextBytes: number
voiceTranscriptCount: number
attachmentMetadataCount: number
sampledChunkCount: number
estimatedChunkCount: number
estimatedDatabaseBytesLow: number
estimatedDatabaseBytesHigh: number
estimatedBuildPeakBytesLow: number
estimatedBuildPeakBytesHigh: number
availableDiskBytes?: number
hasSufficientDiskSpace?: boolean
warnings: string[]
}
export interface KnowledgeEvidence {
chunkId: string
conversationId: string
startTime: number
endTime: number
/** Stable source-message identity used by the archive jump action. */
messageId: string
senderId?: string
sender: string
/** Unix epoch milliseconds. */
timestamp: number
messageIds: string[]
text: string
score?: number
}
/** A bounded, local summary of a single conversation retrieval. */
export interface KnowledgeConversationRetrieval {
conversationId: string
totalMessages: number
chunkCount: number
candidateMessages: number
systemMessagesDeprioritized: number
complete: boolean
}
export interface KnowledgeQuery {
accountId: string
text: string
/** Query-router terms. The raw question remains available for diagnostics. */
terms?: string[]
limit: number
conversationId?: string
conversationIds?: string[]
senderIds?: string[]
/** Unix epoch milliseconds. */
startTime?: number
/** Unix epoch milliseconds. */
endTime?: number
temporalIntent?: KnowledgeTemporalIntent
}
export interface KnowledgeSearchRequest extends KnowledgeQuery {
databaseRoot: string
fts: KnowledgeFtsConfig
}
export type KnowledgeSearchState = 'unavailable' | 'indexing' | 'ready'
/** Measured in the Worker; never inferred from message counts or UI timers. */
export interface KnowledgeSearchTimings {
/** Parent/child-process transport and host scheduling outside SQLite work. */
workerIpcMs: number
/** First request only: child process spawn and Node initialization until it received the request. */
workerBootMs: number
/** Parent send → Worker handler start. */
dispatchMs: number
/** Worker local SQLite/chunk work; equals the Worker-side search total. */
workerSqlMs: number
/** Worker response preparation → parent receipt; includes IPC serialization/transfer. */
responseTransferMs: number
/** Worker-side serialization preflight for the result payload. */
responseSerializeMs: number
/** FTS (or short-term database lookup) query time. */
ftsMs: number
/** Reading source message rows from matching chunks. */
messageLoadMs: number
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */
chunkExpandMs: number
/** Final result ordering and limit application. */
rankingMs: number
/** Worker-side local search total. */
totalMs: number
}
export const emptyKnowledgeSearchTimings = (): KnowledgeSearchTimings => ({
workerIpcMs: 0,
workerBootMs: 0,
dispatchMs: 0,
workerSqlMs: 0,
responseTransferMs: 0,
responseSerializeMs: 0,
ftsMs: 0,
messageLoadMs: 0,
chunkExpandMs: 0,
rankingMs: 0,
totalMs: 0
})
export interface KnowledgeSearchResult {
state: KnowledgeSearchState
evidence: KnowledgeEvidence[]
indexedMessageCount: number
indexedChunkCount: number
timings: KnowledgeSearchTimings
conversationRetrieval?: KnowledgeConversationRetrieval
}
/** Renderer-facing request. Chat timestamps use Unix seconds in the existing UI. */
export interface KnowledgeSearchIpcRequest {
text: string
terms: string[]
conversationIds?: string[]
senderIds?: string[]
startTime?: number
endTime?: number
limit?: number
}
export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
source: 'knowledge' | 'fallback'
totalMessages: number
fallbackReason?: 'unavailable' | 'indexing' | 'error'
}
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error'
export interface KnowledgeRuntimeStatus {
accountId: string
state: KnowledgeRuntimeState
indexedMessageCount: number
indexedChunkCount: number
/** Null means this source pass has not yet counted every source message. */
sourceMessageCount: number | null
processedMessages: number
totalMessages: number | null
currentConversationId?: string
/** Null is displayed as unavailable rather than a fabricated ETA. */
estimatedRemainingMs: number | null
databaseBytes: number
walBytes: number
shmBytes: number
lastError?: string
}
export interface KnowledgeStatusRequest {
accountId: string
databaseRoot: string
fts: KnowledgeFtsConfig
}
export interface KnowledgeWorkerRequest {
version: 1
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close'
requestId: string
/** Parent monotonic wall-clock used only for transport timing. */
sentAt?: number
payload:
| KnowledgeIndexRequest
| KnowledgeCapacityPreflightRequest
| KnowledgeSearchRequest
| KnowledgeStatusRequest
| { accountId: string; databaseRoot: string }
| { targetRequestId: string }
| Record<string, never>
}
export interface KnowledgeWorkerResponse {
version: 1
type: 'progress' | 'result' | 'error'
requestId: string
payload?:
| KnowledgeIndexProgress
| KnowledgeIndexResult
| KnowledgeCapacityPreflight
| KnowledgeSearchResult
| KnowledgeRuntimeStatus
| { removed: true }
error?: string
transport?: {
workerReceivedAt: number
workerCompletedAt: number
responseSerializeMs: number
}
}
export const DEFAULT_KNOWLEDGE_CHUNKER: KnowledgeChunkerConfig = {
version: DEFAULT_CHUNKER_VERSION,
maxGapMs: 10 * 60 * 1000,
maxMessages: 12,
maxCharacters: 1200,
overlapMessages: 3
}