@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
185 lines • 6.69 kB
JavaScript
/**
* Usage data storage
* Persists usage statistics to the app data directory
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAppDataPath, getConfigPath } from '../config/paths.js';
import { MAX_DAILY_AGGREGATES, MAX_USAGE_SESSIONS } from '../constants.js';
import { logInfo, logWarning } from '../utils/message-queue.js';
const USAGE_FILE_NAME = 'usage.json';
function getLegacyUsageFilePath() {
// Legacy location: config directory (pre-app-data change)
try {
const configDir = getConfigPath();
return path.join(configDir, USAGE_FILE_NAME);
}
catch {
return '';
}
}
function getUsageFilePath() {
const appDataDir = getAppDataPath();
const newPath = path.join(appDataDir, USAGE_FILE_NAME);
// If new path already exists, use it
if (fs.existsSync(newPath)) {
return newPath;
}
// Attempt one-time lazy migration from legacy location
const legacyPath = getLegacyUsageFilePath();
if (legacyPath && fs.existsSync(legacyPath)) {
try {
if (!fs.existsSync(appDataDir)) {
fs.mkdirSync(appDataDir, { recursive: true });
}
try {
fs.renameSync(legacyPath, newPath);
logInfo(`Migrated usage data to new location: ${newPath}`);
}
catch (renameError) {
// Fallback if rename/move fails: copy then best-effort delete
logWarning(`Could not move usage file (${renameError instanceof Error ? renameError.message : 'unknown error'}), copying instead...`);
fs.copyFileSync(legacyPath, newPath);
try {
fs.unlinkSync(legacyPath);
logInfo(`Successfully migrated usage data to: ${newPath}`);
}
catch {
logWarning(`Migrated usage data to new location, but could not remove old file at ${legacyPath}. You may want to manually delete it.`);
}
}
return newPath;
}
catch (error) {
// On any failure, fall through to using newPath without migration
logWarning(`Failed to migrate usage data from ${legacyPath}: ${error instanceof Error ? error.message : 'unknown error'}. Old data remains at legacy location.`);
}
}
return newPath;
}
function ensureAppDataDir() {
const appDataDir = getAppDataPath();
if (!fs.existsSync(appDataDir)) {
fs.mkdirSync(appDataDir, { recursive: true });
}
}
function createEmptyUsageData() {
return {
sessions: [],
dailyAggregates: [],
totalLifetime: 0,
lastUpdated: Date.now(),
};
}
export function readUsageData() {
try {
const filePath = getUsageFilePath();
if (!fs.existsSync(filePath)) {
return createEmptyUsageData();
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
return data;
}
catch (error) {
logWarning('Failed to read usage data:', true, {
context: { error },
});
return createEmptyUsageData();
}
}
export function writeUsageData(data) {
try {
ensureAppDataDir();
data.lastUpdated = Date.now();
const filePath = getUsageFilePath();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
catch (error) {
logWarning('Failed to write usage data:', true, {
context: { error },
});
}
}
export function addSession(session) {
const data = readUsageData();
// Add session to the beginning (most recent first)
data.sessions.unshift(session);
// Keep only last MAX_USAGE_SESSIONS
if (data.sessions.length > MAX_USAGE_SESSIONS) {
data.sessions = data.sessions.slice(0, MAX_USAGE_SESSIONS);
}
// Update lifetime total
data.totalLifetime += session.tokens.total;
// Update daily aggregate
updateDailyAggregate(data, session);
writeUsageData(data);
}
function updateDailyAggregate(data, session) {
const dateParts = new Date(session.timestamp).toISOString().split('T');
const dateStr = dateParts[0] || new Date().toISOString().split('T')[0] || '';
// Find or create daily aggregate
let dailyAggregate = data.dailyAggregates.find(agg => agg.date === dateStr);
if (!dailyAggregate) {
dailyAggregate = {
date: dateStr,
sessions: 0,
totalTokens: 0,
providers: {},
models: {},
};
data.dailyAggregates.push(dailyAggregate);
}
// Update aggregate
dailyAggregate.sessions += 1;
dailyAggregate.totalTokens += session.tokens.total;
// Update provider stats
dailyAggregate.providers[session.provider] =
(dailyAggregate.providers[session.provider] || 0) + session.tokens.total;
// Update model stats
dailyAggregate.models[session.model] =
(dailyAggregate.models[session.model] || 0) + session.tokens.total;
// Sort by date (newest first) and keep only last MAX_DAILY_AGGREGATES
data.dailyAggregates.sort((a, b) => b.date.localeCompare(a.date));
if (data.dailyAggregates.length > MAX_DAILY_AGGREGATES) {
data.dailyAggregates = data.dailyAggregates.slice(0, MAX_DAILY_AGGREGATES);
}
}
export function getTodayAggregate() {
const data = readUsageData();
const todayParts = new Date().toISOString().split('T');
const today = todayParts[0] || '';
return data.dailyAggregates.find(agg => agg.date === today) || null;
}
export function getLastNDaysAggregate(days) {
const data = readUsageData();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const cutoffParts = cutoffDate.toISOString().split('T');
const cutoffStr = cutoffParts[0] || '';
const relevantAggregates = data.dailyAggregates.filter(agg => agg.date >= cutoffStr);
const totalTokens = relevantAggregates.reduce((sum, agg) => sum + agg.totalTokens, 0);
const totalSessions = relevantAggregates.reduce((sum, agg) => sum + agg.sessions, 0);
return {
totalTokens,
totalSessions,
avgTokensPerDay: Math.round(totalTokens / (days || 1)),
};
}
/**
* Clear all usage data
*/
export function clearUsageData() {
try {
const filePath = getUsageFilePath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
catch (error) {
logWarning('Failed to clear usage data:', true, {
context: { error },
});
}
}
//# sourceMappingURL=storage.js.map