diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index a7293ff0..1e70f9ff 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -9,6 +9,7 @@ import { SkillManager, buildSkillMenuWithBuiltinChart, createActivateSkillTool, + createDataSnapshotFromOverview, getAllowedBuiltinToolsForChartAutoSkill, getChartCapabilitySkill, getSkillConfigWithBuiltinChart, @@ -166,19 +167,7 @@ export function createCliRunAgentStream( let dataSnapshot: DataSnapshot | undefined if (db) { try { - const overview = getChatOverview(db, 5) - if (overview) { - dataSnapshot = { - name: overview.name, - platform: overview.platform, - type: overview.type, - totalMessages: overview.totalMessages, - totalMembers: overview.totalMembers, - firstMessageTs: overview.firstMessageTs, - lastMessageTs: overview.lastMessageTs, - capturedAt: Math.floor(Date.now() / 1000), - } - } + dataSnapshot = createDataSnapshotFromOverview(getChatOverview(db, 10)) } catch { // non-fatal } diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts index bc65ebe4..69996b80 100644 --- a/apps/desktop/main/ai/agent-stream-runner.ts +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -13,6 +13,7 @@ import { CHART_CAPABILITY_ANALYSIS_TOOLS, checkAndCompress, createCompressionLlmAdapter, + createDataSnapshotFromOverview, formatAIError, getAllowedBuiltinToolsForChartAutoSkill, getChartCapabilitySkill, @@ -214,19 +215,7 @@ export function createElectronRunAgentStream(): ( let dataSnapshot: ToolContext['dataSnapshot'] | undefined try { - const overview = await workerManager.getChatOverview(sessionId, 5) - if (overview) { - dataSnapshot = { - name: overview.name, - platform: overview.platform, - type: overview.type, - totalMessages: overview.totalMessages, - totalMembers: overview.totalMembers, - firstMessageTs: overview.firstMessageTs, - lastMessageTs: overview.lastMessageTs, - capturedAt: Math.floor(Date.now() / 1000), - } - } + dataSnapshot = createDataSnapshotFromOverview(await workerManager.getChatOverview(sessionId, 10)) } catch (error) { aiLogger.warn('AgentStream', `Failed to load data snapshot: ${requestId}`, { error: String(error) }) } diff --git a/apps/desktop/main/worker/query/sessions.ts b/apps/desktop/main/worker/query/sessions.ts index d4556e26..9a1c534c 100644 --- a/apps/desktop/main/worker/query/sessions.ts +++ b/apps/desktop/main/worker/query/sessions.ts @@ -160,6 +160,7 @@ export function getChatOverview(sessionId: string, topN: number = 10) { const cacheDir = getCacheDir() const overview = resolveOverview(db, sessionId, cacheDir) + const summaryCount = getSummaryCount(coreDb) let membersCache = getCache(sessionId, CACHE_KEY_MEMBERS, cacheDir) if (!membersCache) { @@ -187,5 +188,6 @@ export function getChatOverview(sessionId: string, topN: number = 10) { firstMessageTs: overview.firstMessageTs, lastMessageTs: overview.lastMessageTs, topMembers, + summaryCount, } } diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index 02f6e2ab..b0a7977c 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -432,6 +432,7 @@ export async function getChatOverview( firstMessageTs: number | null lastMessageTs: number | null topMembers: Array<{ id: number; name: string; count: number }> + summaryCount: number } | null> { return sendToWorker('getChatOverview', { sessionId, topN }) } diff --git a/packages/core/src/query/__tests__/session-queries.test.ts b/packages/core/src/query/__tests__/session-queries.test.ts index 89902328..66932402 100644 --- a/packages/core/src/query/__tests__/session-queries.test.ts +++ b/packages/core/src/query/__tests__/session-queries.test.ts @@ -10,7 +10,13 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import type { SessionMeta, SessionOverview } from '../session-queries' -import { buildSessionInfo, getSessionInfo, getSummaryCount, getLastPlatformMessageId } from '../session-queries' +import { + buildSessionInfo, + getChatOverview, + getSessionInfo, + getSummaryCount, + getLastPlatformMessageId, +} from '../session-queries' import type { DatabaseAdapter } from '../../interfaces' // ==================== Mock helpers ==================== @@ -141,6 +147,38 @@ describe('getSessionInfo', () => { }) }) +describe('getChatOverview', () => { + it('returns summaryCount together with overview and top members', () => { + const db = createMockDb({ + 'FROM meta': () => ({ + name: 'Chat', + platform: 'wechat', + type: 'group', + imported_at: 1700000000, + group_id: null, + group_avatar: null, + owner_id: null, + }), + 'FROM message msg': () => ({ count: 42 }), + 'FROM member\n WHERE': () => ({ count: 2 }), + 'MIN(ts)': () => ({ v: 1600000000 }), + 'MAX(ts)': () => ({ v: 1700000000 }), + sqlite_master: () => ({ cnt: 1 }), + 'FROM segment': () => ({ count: 5 }), + 'FROM member m': () => [ + { memberId: 1, platformId: 'u1', name: 'Alice', avatar: null, messageCount: 30 }, + { memberId: 2, platformId: 'u2', name: 'Bob', avatar: null, messageCount: 12 }, + ], + }) + + const overview = getChatOverview(db, 1) + + assert.ok(overview) + assert.equal(overview.summaryCount, 5) + assert.deepEqual(overview.topMembers, [{ id: 1, name: 'Alice', count: 30 }]) + }) +}) + describe('getSummaryCount', () => { it('returns 0 when segment table does not exist', () => { const db = createMockDb({ diff --git a/packages/core/src/query/session-queries.ts b/packages/core/src/query/session-queries.ts index a1f05867..ca2ed610 100644 --- a/packages/core/src/query/session-queries.ts +++ b/packages/core/src/query/session-queries.ts @@ -194,6 +194,7 @@ export interface ChatOverviewData { firstMessageTs: number | null lastMessageTs: number | null topMembers: Array<{ id: number; name: string; count: number }> + summaryCount: number } export interface SessionPreviewMessage { @@ -252,6 +253,7 @@ export function getChatOverview(db: DatabaseAdapter, topN: number = 10): ChatOve const overview = getSessionOverview(db) const members = getMemberActivity(db) + const summaryCount = getSummaryCount(db) const topMembers = members.slice(0, topN).map((m) => ({ id: m.memberId, @@ -268,6 +270,7 @@ export function getChatOverview(db: DatabaseAdapter, topN: number = 10): ChatOve firstMessageTs: overview.firstMessageTs, lastMessageTs: overview.lastMessageTs, topMembers, + summaryCount, } } diff --git a/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts new file mode 100644 index 00000000..30a8e61e --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts @@ -0,0 +1,100 @@ +/** + * Tests for DataSnapshot assembly. + * + * Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createDataSnapshotFromOverview } from '../data-snapshot' + +describe('createDataSnapshotFromOverview', () => { + it('maps overview members and summary count into an extended data snapshot', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 200, + totalMembers: 3, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + summaryCount: 7, + topMembers: [ + { id: 1, name: 'Alice', count: 120 }, + { id: 2, name: 'Bob', count: 80 }, + ], + }) + + assert.deepEqual(snapshot, { + version: 2, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 200, + totalMembers: 3, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [ + { memberId: 1, displayName: 'Alice', messageCount: 120, share: 60 }, + { memberId: 2, displayName: 'Bob', messageCount: 80, share: 40 }, + ], + segmentSummaries: { availableCount: 7 }, + }) + }) + + it('keeps all supplied members for small groups and caps large groups at ten hints', () => { + const topMembers = Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + name: `Member ${index + 1}`, + count: 10, + })) + + const smallGroup = createDataSnapshotFromOverview({ + name: 'Small', + platform: 'wechat', + type: 'group', + totalMessages: 120, + totalMembers: 10, + firstMessageTs: null, + lastMessageTs: null, + topMembers: topMembers.slice(0, 10), + }) + const largeGroup = createDataSnapshotFromOverview({ + name: 'Large', + platform: 'wechat', + type: 'group', + totalMessages: 120, + totalMembers: 12, + firstMessageTs: null, + lastMessageTs: null, + topMembers, + }) + + assert.ok(smallGroup?.activeMemberHints) + assert.ok(largeGroup?.activeMemberHints) + assert.equal(smallGroup.activeMemberHints.length, 10) + assert.equal(largeGroup.activeMemberHints.length, 10) + assert.equal(largeGroup.activeMemberHints.at(-1)?.memberId, 10) + }) + + it('returns a usable snapshot without member hints when overview has no topMembers', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'No Cache', + platform: 'wechat', + type: 'group', + totalMessages: 0, + totalMembers: 0, + firstMessageTs: null, + lastMessageTs: null, + }) + + assert.ok(snapshot?.segmentSummaries) + assert.equal(snapshot.segmentSummaries.availableCount, 0) + assert.deepEqual(snapshot?.activeMemberHints, []) + }) + + it('returns undefined for missing overview', () => { + assert.equal(createDataSnapshotFromOverview(null), undefined) + assert.equal(createDataSnapshotFromOverview(undefined), undefined) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/data-snapshot.ts b/packages/node-runtime/src/ai/agent/data-snapshot.ts new file mode 100644 index 00000000..c028c373 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/data-snapshot.ts @@ -0,0 +1,46 @@ +import type { DataSnapshot } from './prompt-builder' + +export interface ChatOverviewForSnapshot { + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + topMembers?: Array<{ id: number; name: string; count: number }> + summaryCount?: number +} + +function roundShare(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.round(value * 10) / 10 +} + +export function createDataSnapshotFromOverview( + overview: ChatOverviewForSnapshot | null | undefined +): DataSnapshot | undefined { + if (!overview) return undefined + + const topMembers = overview.topMembers ?? [] + + return { + version: 2, + name: overview.name, + platform: overview.platform, + type: overview.type, + totalMessages: overview.totalMessages, + totalMembers: overview.totalMembers, + firstMessageTs: overview.firstMessageTs, + lastMessageTs: overview.lastMessageTs, + activeMemberHints: topMembers.slice(0, 10).map((member) => ({ + memberId: member.id, + displayName: member.name, + messageCount: member.count, + share: overview.totalMessages > 0 ? roundShare((member.count / overview.totalMessages) * 100) : 0, + })), + segmentSummaries: { + availableCount: overview.summaryCount ?? 0, + }, + } +} diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts index bce9b9d6..ffaf379f 100644 --- a/packages/node-runtime/src/ai/agent/index.ts +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -4,6 +4,8 @@ export { DEFAULT_MAX_TOOL_ROUNDS } from './constants' export { createLlmRouteDecider, decideRequestRoute } from './router' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './routing-types' export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './planner' +export { createDataSnapshotFromOverview } from './data-snapshot' +export type { ChatOverviewForSnapshot } from './data-snapshot' export type { AnalysisPlanIntent, AnalysisPlanner, diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 37dc0f9d..403a2ad5 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -102,6 +102,7 @@ export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './agent' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './agent' export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './agent' +export { createDataSnapshotFromOverview } from './agent' export type { AnalysisPlanIntent, AnalysisPlanner, @@ -111,6 +112,7 @@ export type { PlannerInput, PlanContentBlock, PlanDraftContentBlock, + ChatOverviewForSnapshot, } from './agent' // Agent Event Handler diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index e3d5bbc6..f32435fa 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -207,7 +207,7 @@ export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData, AI export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai' export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './ai' export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './ai' -export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './ai' +export { buildPlanGuidance, createAnalysisPlanner, createDataSnapshotFromOverview, createPlanContentBlock } from './ai' export type { AnalysisPlanIntent, AnalysisPlanner, @@ -217,6 +217,7 @@ export type { PlannerInput, PlanContentBlock, PlanDraftContentBlock, + ChatOverviewForSnapshot, } from './ai' // Summary generation