Files
cc-web/lib/usage-statistics.js

913 lines
30 KiB
JavaScript

'use strict';
const fsp = require('fs/promises');
const path = require('path');
const SCHEMA_VERSION = 1;
const CACHE_VERSION = 1;
const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;
const DEFAULT_MAX_RANGE_DAYS = 370;
const DEFAULT_RECENT_LIMIT = 50;
const DEFAULT_MCP_TOOL_LIMIT = 200;
const DEFAULT_SKILL_LIMIT = 100;
const DEFAULT_PROJECT_LIMIT = 100;
const DEFAULT_MCP_DETAIL_LIMIT = 250;
const BUILD_BATCH_SIZE = 1;
const UPSERT_DEBOUNCE_MS = 300;
const PERSIST_DEBOUNCE_MS = 600;
const SESSION_ID_RE = /^[a-zA-Z0-9-]+$/;
class UsageStatisticsError extends Error {
constructor(code, message) {
super(message);
this.name = 'UsageStatisticsError';
this.code = code;
}
}
function isObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clampInteger(value, fallback, min, max) {
const number = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(number)) return fallback;
return Math.max(min, Math.min(max, number));
}
function cleanDisplayText(value, maxChars = 240) {
if (typeof value !== 'string') return '';
return value
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxChars);
}
function sanitizeSessionId(value) {
return String(value || '').replace(/[^a-zA-Z0-9-]/g, '');
}
function legalSessionId(value) {
return !!value && SESSION_ID_RE.test(String(value));
}
function safeTimestamp(value) {
if (!value) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
function safeIso(value, fallbackMs = null) {
const timestamp = safeTimestamp(value);
if (timestamp !== null) return new Date(timestamp).toISOString();
return Number.isFinite(fallbackMs) ? new Date(fallbackMs).toISOString() : null;
}
function basenameFromCwd(value) {
const cwd = cleanDisplayText(value, 1200);
return cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '';
}
function normalizeAgent(value) {
return cleanDisplayText(String(value || 'codex'), 40).toLowerCase() || 'codex';
}
function parseMaybeObject(value) {
if (isObject(value)) return value;
if (typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return isObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function normalizeMcpStatus(value, done = false) {
const status = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '');
if (status === 'failed' || status === 'error') return 'failed';
if (status === 'completed' || status === 'complete' || status === 'succeeded' || status === 'success') {
return 'completed';
}
if (status === 'cancelled' || status === 'canceled') return 'cancelled';
if (status === 'inprogress' || status === 'running' || status === 'pending') return 'in_progress';
return done ? 'completed' : 'in_progress';
}
function splitMcpSubtitle(value) {
const subtitle = cleanDisplayText(value, 320);
const index = subtitle.indexOf('.');
if (index <= 0 || index >= subtitle.length - 1) return null;
return {
server: subtitle.slice(0, index),
tool: subtitle.slice(index + 1),
};
}
function extractMcpToolCall(toolCall) {
if (!isObject(toolCall)) return null;
const meta = isObject(toolCall.meta) ? toolCall.meta : {};
const compactKind = String(toolCall.kind || meta.kind || '').toLowerCase().replace(/[\s_-]+/g, '');
const compactName = String(toolCall.name || '').toLowerCase().replace(/[\s_-]+/g, '');
const input = parseMaybeObject(toolCall.input) || {};
const looksLikeMcp = compactKind === 'mcptoolcall'
|| compactName === 'mcptoolcall'
|| (typeof toolCall.name === 'string' && toolCall.name.startsWith('mcp__'))
|| (!!input.server && !!input.tool);
if (!looksLikeMcp) return null;
let server = cleanDisplayText(String(input.server || ''), 160);
let tool = cleanDisplayText(String(input.tool || ''), 200);
if ((!server || !tool) && typeof toolCall.name === 'string' && toolCall.name.startsWith('mcp__')) {
const parts = toolCall.name.split('__');
if (parts.length >= 3) {
server ||= cleanDisplayText(parts[1], 160);
tool ||= cleanDisplayText(parts.slice(2).join('__'), 200);
}
}
if (!server || !tool) {
const subtitle = splitMcpSubtitle(meta.subtitle);
if (subtitle) {
server ||= subtitle.server;
tool ||= subtitle.tool;
}
}
if (!server || !tool) return null;
return {
server,
tool,
status: normalizeMcpStatus(meta.status || toolCall.status, !!toolCall.done),
};
}
function extractSkillMention(mention) {
if (!isObject(mention) || String(mention.kind || '').toLowerCase() !== 'skill') return null;
const name = cleanDisplayText(String(mention.name || mention.title || mention.label || ''), 160).replace(/^\$/, '');
if (!name) return null;
return {
name,
label: cleanDisplayText(String(mention.label || mention.title || `$${name}`), 180) || `$${name}`,
};
}
function fileFingerprint(stat) {
return {
dev: Number(stat?.dev) || 0,
ino: Number(stat?.ino) || 0,
size: Number(stat?.size) || 0,
mtimeMs: Number(stat?.mtimeMs) || 0,
};
}
function sameFingerprint(left, right) {
return !!left
&& !!right
&& left.dev === right.dev
&& left.ino === right.ino
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs;
}
function createUsageDocument(session, stat, fallbackId) {
const source = isObject(session) ? session : {};
const fallback = sanitizeSessionId(fallbackId);
const sourceId = sanitizeSessionId(source.id);
const sessionId = legalSessionId(fallback) ? fallback : sourceId;
if (!legalSessionId(sessionId)) return null;
const created = safeIso(source.created || source.createdAt, stat?.birthtimeMs || stat?.mtimeMs);
const updated = safeIso(source.updated || source.updatedAt, stat?.mtimeMs) || created;
const cwd = cleanDisplayText(String(source.cwd || ''), 1200);
const title = cleanDisplayText(String(source.title || '未命名会话'), 240) || '未命名会话';
const events = [];
const messages = Array.isArray(source.messages) ? source.messages : [];
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
const message = messages[messageIndex];
if (!isObject(message)) continue;
const timestampMs = safeTimestamp(message.timestamp || message.created || message.createdAt);
if (timestampMs === null) continue;
const timestamp = new Date(timestampMs).toISOString();
const role = String(message.role || '').toLowerCase();
if (role === 'user') {
events.push({
type: 'message',
timestamp,
channel: message.crossConversation ? 'cross_conversation' : 'direct',
});
for (const mention of Array.isArray(message.composerMentions) ? message.composerMentions : []) {
const skill = extractSkillMention(mention);
if (!skill) continue;
events.push({ type: 'skill', timestamp, name: skill.name, label: skill.label });
}
}
if (role === 'assistant') {
for (const toolCall of Array.isArray(message.toolCalls) ? message.toolCalls : []) {
const mcp = extractMcpToolCall(toolCall);
if (!mcp) continue;
events.push({
type: 'mcp',
timestamp,
server: mcp.server,
tool: mcp.tool,
status: mcp.status,
messageIndex,
});
}
}
}
return {
sessionId,
title,
cwd,
projectName: cleanDisplayText(String(source.projectName || source.project || basenameFromCwd(cwd)), 240),
agent: normalizeAgent(source.agent),
created,
updated,
sourceMessageCount: messages.length,
events,
};
}
function validateTimeZone(value) {
const timeZone = cleanDisplayText(String(value || 'UTC'), 80) || 'UTC';
try {
new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
} catch {
throw new UsageStatisticsError('invalid_time_zone', '无效的时区');
}
return timeZone;
}
function normalizeUsageRange(params = {}, maxRangeDays = DEFAULT_MAX_RANGE_DAYS) {
const fromMs = safeTimestamp(params.from);
const toMs = safeTimestamp(params.to);
if (fromMs === null || toMs === null) {
throw new UsageStatisticsError('invalid_range', '统计时间范围无效');
}
if (fromMs >= toMs) {
throw new UsageStatisticsError('invalid_range', '统计开始时间必须早于结束时间');
}
const maxMs = Math.max(1, maxRangeDays) * 24 * 60 * 60 * 1000;
if (toMs - fromMs > maxMs) {
throw new UsageStatisticsError('range_too_large', `统计时间范围不能超过 ${maxRangeDays}`);
}
return {
fromMs,
toMs,
from: new Date(fromMs).toISOString(),
to: new Date(toMs).toISOString(),
timeZone: validateTimeZone(params.timeZone),
bucket: 'day',
};
}
function createDayKeyFormatter(timeZone) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return (timestampMs) => {
const parts = formatter.formatToParts(new Date(timestampMs));
const values = {};
for (const part of parts) {
if (part.type !== 'literal') values[part.type] = part.value;
}
return `${values.year}-${values.month}-${values.day}`;
};
}
function createTrendBucket(date) {
return {
date,
newSessions: 0,
messages: 0,
directMessages: 0,
crossConversationMessages: 0,
mcpCalls: 0,
mcpFailures: 0,
skillMentions: 0,
projects: 0,
};
}
function createTrendBuckets(range, dayKey) {
const keys = new Set([dayKey(range.fromMs), dayKey(range.toMs - 1)]);
const halfDayMs = 12 * 60 * 60 * 1000;
for (let cursor = range.fromMs; cursor < range.toMs; cursor += halfDayMs) {
keys.add(dayKey(cursor));
}
const map = new Map();
Array.from(keys).sort().forEach((key) => map.set(key, createTrendBucket(key)));
return map;
}
function eventInRange(timestamp, range) {
const value = safeTimestamp(timestamp);
return value !== null && value >= range.fromMs && value < range.toMs ? value : null;
}
function aggregateUsageStatistics(documents, params = {}, meta = {}) {
const range = normalizeUsageRange(params, meta.maxRangeDays || DEFAULT_MAX_RANGE_DAYS);
const recentLimit = clampInteger(params.recentLimit, DEFAULT_RECENT_LIMIT, 1, 100);
const mcpToolLimit = clampInteger(params.mcpToolLimit, DEFAULT_MCP_TOOL_LIMIT, 1, 500);
const skillLimit = clampInteger(params.skillLimit, DEFAULT_SKILL_LIMIT, 1, 500);
const projectLimit = clampInteger(params.projectLimit, DEFAULT_PROJECT_LIMIT, 1, 500);
const mcpDetailLimit = clampInteger(params.mcpDetailLimit, DEFAULT_MCP_DETAIL_LIMIT, 1, 500);
const dayKey = createDayKeyFormatter(range.timeZone);
const trendMap = createTrendBuckets(range, dayKey);
const trendProjectKeys = new Map(Array.from(trendMap.keys(), (key) => [key, new Set()]));
const overview = {
newSessions: 0,
messages: 0,
directMessages: 0,
crossConversationMessages: 0,
mcpCalls: 0,
mcpFailures: 0,
skillMentions: 0,
projects: 0,
projectSessions: 0,
unassignedProjectSessions: 0,
};
const mcpStatus = { completed: 0, failed: 0, other: 0 };
const mcpTools = new Map();
const skills = new Map();
const projects = new Map();
const mcpRecentCalls = [];
const recentSessions = [];
let retainedMessages = 0;
for (const doc of documents || []) {
if (!doc || !doc.sessionId) continue;
retainedMessages += Math.max(0, Number(doc.sourceMessageCount || 0));
const projectCwd = cleanDisplayText(String(doc.cwd || ''), 1200).replace(/\\/g, '/').replace(/\/+$/, '');
const projectName = cleanDisplayText(String(doc.projectName || basenameFromCwd(projectCwd)), 240);
const projectKey = projectName ? (projectCwd ? `cwd:${projectCwd}` : `name:${projectName}`) : '';
const sessionSummary = {
sessionId: doc.sessionId,
title: doc.title || '未命名会话',
agent: doc.agent || 'codex',
cwd: doc.cwd || '',
projectName,
created: doc.created || null,
lastActivity: null,
newSession: false,
messages: 0,
directMessages: 0,
crossConversationMessages: 0,
mcpCalls: 0,
mcpFailures: 0,
skillMentions: 0,
};
const createdMs = eventInRange(doc.created, range);
if (createdMs !== null) {
overview.newSessions += 1;
sessionSummary.newSession = true;
sessionSummary.lastActivity = new Date(createdMs).toISOString();
const bucket = trendMap.get(dayKey(createdMs));
if (bucket) {
bucket.newSessions += 1;
if (projectKey) trendProjectKeys.get(bucket.date)?.add(projectKey);
}
}
for (const event of Array.isArray(doc.events) ? doc.events : []) {
const timestampMs = eventInRange(event.timestamp, range);
if (timestampMs === null) continue;
const timestamp = new Date(timestampMs).toISOString();
if (!sessionSummary.lastActivity || timestamp > sessionSummary.lastActivity) {
sessionSummary.lastActivity = timestamp;
}
const bucket = trendMap.get(dayKey(timestampMs));
if (!bucket) continue;
if (projectKey) trendProjectKeys.get(bucket.date)?.add(projectKey);
if (event.type === 'message') {
overview.messages += 1;
sessionSummary.messages += 1;
bucket.messages += 1;
if (event.channel === 'cross_conversation') {
overview.crossConversationMessages += 1;
sessionSummary.crossConversationMessages += 1;
bucket.crossConversationMessages += 1;
} else {
overview.directMessages += 1;
sessionSummary.directMessages += 1;
bucket.directMessages += 1;
}
} else if (event.type === 'mcp') {
overview.mcpCalls += 1;
sessionSummary.mcpCalls += 1;
bucket.mcpCalls += 1;
const failed = event.status === 'failed';
if (failed) {
overview.mcpFailures += 1;
sessionSummary.mcpFailures += 1;
bucket.mcpFailures += 1;
mcpStatus.failed += 1;
} else if (event.status === 'completed') {
mcpStatus.completed += 1;
} else {
mcpStatus.other += 1;
}
const key = `${event.server}\u0000${event.tool}`;
const current = mcpTools.get(key) || {
key: `${event.server}/${event.tool}`,
server: event.server,
tool: event.tool,
calls: 0,
completed: 0,
failed: 0,
other: 0,
successRate: 0,
lastUsedAt: null,
};
current.calls += 1;
if (failed) current.failed += 1;
else if (event.status === 'completed') current.completed += 1;
else current.other += 1;
if (!current.lastUsedAt || timestamp > current.lastUsedAt) current.lastUsedAt = timestamp;
mcpTools.set(key, current);
mcpRecentCalls.push({
timestamp,
server: event.server,
tool: event.tool,
status: event.status,
sessionId: doc.sessionId,
sessionTitle: doc.title || '未命名会话',
agent: doc.agent || 'codex',
messageIndex: Number.isFinite(Number(event.messageIndex)) ? Number(event.messageIndex) : null,
});
} else if (event.type === 'skill') {
overview.skillMentions += 1;
sessionSummary.skillMentions += 1;
bucket.skillMentions += 1;
const name = cleanDisplayText(String(event.name || ''), 160);
if (!name) continue;
const current = skills.get(name) || {
name,
label: cleanDisplayText(String(event.label || `$${name}`), 180) || `$${name}`,
uses: 0,
lastUsedAt: null,
};
current.uses += 1;
if (!current.lastUsedAt || timestamp > current.lastUsedAt) current.lastUsedAt = timestamp;
skills.set(name, current);
}
}
if (sessionSummary.lastActivity) {
if (projectKey) {
const current = projects.get(projectKey) || {
key: projectKey,
name: projectName,
cwd: projectCwd,
sessions: 0,
messages: 0,
mcpCalls: 0,
lastActiveAt: null,
};
current.sessions += 1;
current.messages += sessionSummary.messages;
current.mcpCalls += sessionSummary.mcpCalls;
if (!current.lastActiveAt || sessionSummary.lastActivity > current.lastActiveAt) {
current.lastActiveAt = sessionSummary.lastActivity;
}
projects.set(projectKey, current);
overview.projectSessions += 1;
} else {
overview.unassignedProjectSessions += 1;
}
recentSessions.push(sessionSummary);
}
}
for (const bucket of trendMap.values()) {
bucket.projects = trendProjectKeys.get(bucket.date)?.size || 0;
}
const mcpToolRows = Array.from(mcpTools.values());
for (const row of mcpToolRows) {
row.successRate = row.calls > 0 ? Math.round((row.completed / row.calls) * 1000) / 10 : 0;
}
mcpToolRows.sort((a, b) => b.calls - a.calls
|| b.failed - a.failed
|| a.server.localeCompare(b.server)
|| a.tool.localeCompare(b.tool));
const skillRows = Array.from(skills.values()).sort((a, b) => b.uses - a.uses || a.name.localeCompare(b.name));
const projectRows = Array.from(projects.values()).sort((a, b) => b.messages - a.messages
|| b.mcpCalls - a.mcpCalls
|| b.sessions - a.sessions
|| a.name.localeCompare(b.name)
|| a.key.localeCompare(b.key));
overview.projects = projectRows.length;
recentSessions.sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)) || a.sessionId.localeCompare(b.sessionId));
mcpRecentCalls.sort((a, b) => b.timestamp.localeCompare(a.timestamp)
|| a.server.localeCompare(b.server)
|| a.tool.localeCompare(b.tool)
|| a.sessionId.localeCompare(b.sessionId));
const returnedMcpTools = mcpToolRows.slice(0, mcpToolLimit);
const returnedMcpToolKeys = new Set(returnedMcpTools.map((row) => `${row.server}\u0000${row.tool}`));
const returnedMcpRecentCalls = mcpRecentCalls
.filter((call) => returnedMcpToolKeys.has(`${call.server}\u0000${call.tool}`))
.slice(0, mcpDetailLimit);
const returnedDetailCounts = new Map();
for (const call of returnedMcpRecentCalls) {
const key = `${call.server}\u0000${call.tool}`;
returnedDetailCounts.set(key, (returnedDetailCounts.get(key) || 0) + 1);
}
for (const row of returnedMcpTools) {
row.detailCallsReturned = returnedDetailCounts.get(`${row.server}\u0000${row.tool}`) || 0;
}
const returnedSkills = skillRows.slice(0, skillLimit);
const returnedProjects = projectRows.slice(0, projectLimit);
return {
schemaVersion: SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
range: {
from: range.from,
to: range.to,
timeZone: range.timeZone,
bucket: range.bucket,
semantics: '[from,to)',
},
coverage: {
scope: 'retained_sessions',
indexedSessions: Number(meta.indexedSessions ?? (documents || []).length) || 0,
trackedFiles: Number(meta.trackedFiles ?? (documents || []).length) || 0,
retainedMessages,
skippedFiles: Number(meta.skippedFiles || 0),
failedFiles: Number(meta.failedFiles || 0),
distinctMcpTools: mcpToolRows.length,
returnedMcpTools: returnedMcpTools.length,
distinctSkills: skillRows.length,
returnedSkills: returnedSkills.length,
distinctProjects: projectRows.length,
returnedProjects: returnedProjects.length,
lastBuiltAt: meta.lastBuiltAt || null,
mcpTimestampBasis: 'assistant_message',
skillBasis: 'explicit_composer_mention',
},
overview,
trend: Array.from(trendMap.values()),
mcpStatus,
limits: {
mcpTools: mcpToolLimit,
skills: skillLimit,
projects: projectLimit,
mcpRecentCalls: mcpDetailLimit,
recentSessions: recentLimit,
},
mcpTools: returnedMcpTools,
mcpRecentCalls: returnedMcpRecentCalls,
skills: returnedSkills,
projects: returnedProjects,
recentSessions: recentSessions.slice(0, recentLimit),
};
}
function safeError(error) {
return cleanDisplayText(error?.message || String(error || 'unknown error'), 500);
}
function sleepImmediate() {
return new Promise((resolve) => setImmediate(resolve));
}
function timerUnref(timer) {
if (timer && typeof timer.unref === 'function') timer.unref();
}
async function writeJsonAtomic(filePath, value) {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
await fsp.writeFile(tempFile, JSON.stringify(value), 'utf8');
await fsp.rename(tempFile, filePath);
} catch (error) {
await fsp.unlink(tempFile).catch(() => {});
throw error;
}
}
function createUsageStatisticsIndex(options = {}) {
const sessionsDir = path.resolve(options.sessionsDir || path.join(process.cwd(), 'sessions'));
const cacheFile = path.resolve(options.cacheFile || path.join(sessionsDir, '_usage', 'index-v1.json'));
const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 64 * 1024, 1024 * 1024 * 1024);
const maxRangeDays = clampInteger(options.maxRangeDays, DEFAULT_MAX_RANGE_DAYS, 1, 3660);
const logger = options.logger || null;
const documents = new Map();
const fileMeta = new Map();
const upsertTimers = new Map();
let state = 'idle';
let initialized = false;
let buildPromise = null;
let persistPromise = null;
let persistTimer = null;
let dirty = false;
let skippedFiles = 0;
let failedFiles = 0;
let lastBuiltAt = null;
let lastPersistedAt = null;
let lastError = null;
function log(level, event, data = {}) {
try {
if (logger && typeof logger[level] === 'function') logger[level](event, data);
else if (logger && typeof logger.log === 'function') logger.log(event, data);
} catch {}
}
function status() {
return {
state,
ready: state === 'ready',
building: state === 'building',
initialized,
error: lastError,
sessionsDir,
cacheFile,
indexedSessions: documents.size,
trackedFiles: fileMeta.size,
pendingUpserts: upsertTimers.size,
skippedFiles,
failedFiles,
lastBuiltAt,
lastPersistedAt,
};
}
function serializeCache() {
return {
version: CACHE_VERSION,
generatedAt: new Date().toISOString(),
files: Object.fromEntries(fileMeta.entries()),
documents: Array.from(documents.values()),
};
}
async function loadCache() {
try {
const stat = await fsp.stat(cacheFile);
if (!stat.isFile() || stat.size <= 0 || stat.size > 256 * 1024 * 1024) return;
const parsed = JSON.parse(await fsp.readFile(cacheFile, 'utf8'));
if (!isObject(parsed) || parsed.version !== CACHE_VERSION || !Array.isArray(parsed.documents)) return;
documents.clear();
fileMeta.clear();
for (const doc of parsed.documents) {
if (doc && legalSessionId(doc.sessionId)) documents.set(doc.sessionId, doc);
}
if (isObject(parsed.files)) {
for (const [sessionId, fingerprint] of Object.entries(parsed.files)) {
if (legalSessionId(sessionId) && isObject(fingerprint)) fileMeta.set(sessionId, fingerprint);
}
}
lastPersistedAt = safeIso(parsed.generatedAt);
} catch (error) {
if (error?.code !== 'ENOENT') {
dirty = true;
log('warn', 'usage_statistics_cache_load_failed', { error: safeError(error) });
}
}
}
async function scanSessionFiles() {
const scanned = new Map();
let entries = [];
try {
entries = await fsp.readdir(sessionsDir, { withFileTypes: true });
} catch (error) {
if (error?.code === 'ENOENT') return scanned;
throw error;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const sessionId = entry.name.slice(0, -5);
if (!legalSessionId(sessionId)) continue;
const filePath = path.join(sessionsDir, entry.name);
try {
const stat = await fsp.stat(filePath);
if (!stat.isFile() || stat.size <= 0 || stat.size > maxFileBytes) {
skippedFiles += 1;
continue;
}
scanned.set(sessionId, { filePath, stat, fingerprint: fileFingerprint(stat) });
} catch (error) {
failedFiles += 1;
log('warn', 'usage_statistics_file_stat_failed', { sessionId: sessionId.slice(0, 8), error: safeError(error) });
}
}
return scanned;
}
async function readAndIndexFile(sessionId, filePath, stat, fingerprint) {
try {
const raw = await fsp.readFile(filePath, 'utf8');
if (Buffer.byteLength(raw) > maxFileBytes) {
skippedFiles += 1;
return false;
}
const doc = createUsageDocument(JSON.parse(raw), stat, sessionId);
if (!doc) throw new Error('invalid session document');
documents.set(sessionId, doc);
fileMeta.set(sessionId, fingerprint || fileFingerprint(stat));
dirty = true;
return true;
} catch (error) {
failedFiles += 1;
documents.delete(sessionId);
fileMeta.delete(sessionId);
dirty = true;
log('warn', 'usage_statistics_file_index_failed', { sessionId: sessionId.slice(0, 8), error: safeError(error) });
return false;
}
}
async function persistNow() {
if (persistPromise) return persistPromise;
persistPromise = (async () => {
while (dirty) {
dirty = false;
await writeJsonAtomic(cacheFile, serializeCache());
lastPersistedAt = new Date().toISOString();
}
})().finally(() => {
persistPromise = null;
});
return persistPromise;
}
function schedulePersist() {
dirty = true;
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
persistNow().catch((error) => {
state = 'error';
lastError = safeError(error);
log('error', 'usage_statistics_cache_persist_failed', { error: lastError });
});
}, PERSIST_DEBOUNCE_MS);
timerUnref(persistTimer);
}
async function rebuildChangedFiles() {
state = 'building';
lastError = null;
skippedFiles = 0;
failedFiles = 0;
await loadCache();
const scanned = await scanSessionFiles();
for (const sessionId of Array.from(documents.keys())) {
if (!scanned.has(sessionId)) {
documents.delete(sessionId);
fileMeta.delete(sessionId);
dirty = true;
}
}
const changed = [];
for (const [sessionId, entry] of scanned.entries()) {
if (!sameFingerprint(fileMeta.get(sessionId), entry.fingerprint) || !documents.has(sessionId)) {
changed.push([sessionId, entry]);
}
}
for (let index = 0; index < changed.length; index += 1) {
const [sessionId, entry] = changed[index];
await readAndIndexFile(sessionId, entry.filePath, entry.stat, entry.fingerprint);
if ((index + 1) % BUILD_BATCH_SIZE === 0) await sleepImmediate();
}
if (dirty) await persistNow();
lastBuiltAt = new Date().toISOString();
initialized = true;
state = 'ready';
return status();
}
function initialize() {
if (buildPromise) return buildPromise;
if (initialized && state === 'ready') return Promise.resolve(status());
buildPromise = rebuildChangedFiles()
.catch((error) => {
state = 'error';
lastError = safeError(error);
log('error', 'usage_statistics_initialize_failed', { error: lastError });
return status();
})
.finally(() => {
buildPromise = null;
});
return buildPromise;
}
async function upsertNow(sessionId) {
const id = sanitizeSessionId(sessionId);
if (!legalSessionId(id)) return false;
const filePath = path.join(sessionsDir, `${id}.json`);
try {
const stat = await fsp.stat(filePath);
if (!stat.isFile() || stat.size <= 0 || stat.size > maxFileBytes) {
const existed = documents.delete(id) || fileMeta.delete(id);
fileMeta.delete(id);
if (existed) schedulePersist();
return false;
}
const changed = !sameFingerprint(fileMeta.get(id), fileFingerprint(stat)) || !documents.has(id);
if (!changed) return true;
const result = await readAndIndexFile(id, filePath, stat, fileFingerprint(stat));
if (result) schedulePersist();
return result;
} catch (error) {
if (error?.code === 'ENOENT') return remove(id);
failedFiles += 1;
log('warn', 'usage_statistics_upsert_failed', { sessionId: id.slice(0, 8), error: safeError(error) });
return false;
}
}
function scheduleUpsert(sessionId) {
if (!initialized && !buildPromise) return false;
const id = sanitizeSessionId(sessionId);
if (!legalSessionId(id)) return false;
const existing = upsertTimers.get(id);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
upsertTimers.delete(id);
upsertNow(id).catch((error) => {
lastError = safeError(error);
log('error', 'usage_statistics_schedule_upsert_failed', { sessionId: id.slice(0, 8), error: lastError });
});
}, UPSERT_DEBOUNCE_MS);
timerUnref(timer);
upsertTimers.set(id, timer);
return true;
}
function remove(sessionId) {
if (!initialized && !buildPromise) return false;
const id = sanitizeSessionId(sessionId);
if (!legalSessionId(id)) return false;
const timer = upsertTimers.get(id);
if (timer) clearTimeout(timer);
upsertTimers.delete(id);
const existed = documents.delete(id) || fileMeta.delete(id);
fileMeta.delete(id);
if (existed) schedulePersist();
return existed;
}
async function flush() {
if (buildPromise) await buildPromise;
for (const id of Array.from(upsertTimers.keys())) {
const timer = upsertTimers.get(id);
if (timer) clearTimeout(timer);
upsertTimers.delete(id);
await upsertNow(id);
}
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
if (dirty) await persistNow();
if (persistPromise) await persistPromise;
return status();
}
function query(params = {}) {
if (!initialized || state !== 'ready') {
throw new UsageStatisticsError('index_unavailable', '统计索引暂不可用');
}
return aggregateUsageStatistics(Array.from(documents.values()), params, {
...status(),
maxRangeDays,
});
}
return {
initialize,
scheduleUpsert,
remove,
flush,
query,
status,
};
}
module.exports = {
SCHEMA_VERSION,
UsageStatisticsError,
createUsageStatisticsIndex,
createUsageDocument,
aggregateUsageStatistics,
normalizeUsageRange,
extractMcpToolCall,
extractSkillMention,
};