aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
227 lines • 6.86 kB
JavaScript
/**
* Cache manager for research API responses
*
* @module research/cache/manager
*/
import { promises as fs } from 'fs';
import { join, dirname } from 'path';
import { createHash } from 'crypto';
import { ResearchError, ResearchErrorCode, } from '../types.js';
/**
* Cache manager for file-based caching
*/
export class CacheManager {
config;
constructor(config) {
this.config = {
cacheDir: '.aiwg/research/cache',
defaultTtl: 86400, // 24 hours
ttlByEndpoint: {
'semantic-scholar': 604800, // 7 days
'crossref': 604800, // 7 days
'arxiv': 2592000, // 30 days (preprints don't change)
'unpaywall': 86400, // 24 hours
},
...config,
};
}
/**
* Get cached data
*/
async get(key) {
try {
const filePath = this.getFilePath(key);
const data = await fs.readFile(filePath, 'utf-8');
const entry = JSON.parse(data);
// Check if expired
if (this.isExpired(entry)) {
await this.delete(key);
return null;
}
return entry.data;
}
catch (error) {
if (error.code === 'ENOENT') {
return null; // Cache miss
}
throw new ResearchError(ResearchErrorCode.RF_400, 'Cache read error', error);
}
}
/**
* Set cached data
*/
async set(key, data, endpoint) {
try {
const ttl = endpoint
? this.config.ttlByEndpoint?.[endpoint] || this.config.defaultTtl
: this.config.defaultTtl;
const entry = {
data,
cachedAt: new Date().toISOString(),
ttl,
key,
};
const filePath = this.getFilePath(key);
await this.ensureDir(dirname(filePath));
await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8');
}
catch (error) {
throw new ResearchError(ResearchErrorCode.RF_401, 'Cache write error', error);
}
}
/**
* Delete cached data
*/
async delete(key) {
try {
const filePath = this.getFilePath(key);
await fs.unlink(filePath);
}
catch (error) {
if (error.code !== 'ENOENT') {
throw new ResearchError(ResearchErrorCode.RF_402, 'Cache deletion error', error);
}
}
}
/**
* Clear all cache
*/
async clear() {
try {
const files = await this.listCacheFiles();
await Promise.all(files.map((file) => fs.unlink(file)));
}
catch (error) {
throw new ResearchError(ResearchErrorCode.RF_402, 'Cache clear error', error);
}
}
/**
* Clear expired entries
*/
async clearExpired() {
try {
const files = await this.listCacheFiles();
let cleared = 0;
for (const file of files) {
try {
const data = await fs.readFile(file, 'utf-8');
const entry = JSON.parse(data);
if (this.isExpired(entry)) {
await fs.unlink(file);
cleared++;
}
}
catch {
// Skip invalid files
}
}
return cleared;
}
catch (error) {
throw new ResearchError(ResearchErrorCode.RF_402, 'Cache cleanup error', error);
}
}
/**
* Get cache statistics
*/
async getStats() {
try {
const files = await this.listCacheFiles();
let expiredEntries = 0;
let sizeBytes = 0;
for (const file of files) {
try {
const stats = await fs.stat(file);
sizeBytes += stats.size;
const data = await fs.readFile(file, 'utf-8');
const entry = JSON.parse(data);
if (this.isExpired(entry)) {
expiredEntries++;
}
}
catch {
// Skip invalid files
}
}
return {
totalEntries: files.length,
expiredEntries,
sizeBytes,
};
}
catch (error) {
throw new ResearchError(ResearchErrorCode.RF_400, 'Cache stats error', error);
}
}
/**
* Generate cache key from input
*/
generateKey(endpoint, params) {
// Sort keys to ensure consistent hashing
const sortedKeys = Object.keys(params).sort();
const sortedParams = {};
for (const key of sortedKeys) {
sortedParams[key] = params[key];
}
const normalized = JSON.stringify({ endpoint, params: sortedParams });
return createHash('sha256').update(normalized).digest('hex');
}
/**
* Check if cache entry is expired
*/
isExpired(entry) {
const cachedTime = new Date(entry.cachedAt).getTime();
const expiryTime = cachedTime + entry.ttl * 1000;
return Date.now() > expiryTime;
}
/**
* Get file path for cache key
*/
getFilePath(key) {
// Use first 2 chars for subdirectory to avoid too many files in one dir
const subdir = key.substring(0, 2);
return join(this.config.cacheDir, subdir, `${key}.json`);
}
/**
* Ensure directory exists
*/
async ensureDir(dir) {
try {
await fs.mkdir(dir, { recursive: true });
}
catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
}
/**
* List all cache files
*/
async listCacheFiles() {
const files = [];
try {
const entries = await fs.readdir(this.config.cacheDir, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = join(this.config.cacheDir, entry.name);
if (entry.isDirectory()) {
const subFiles = await fs.readdir(fullPath);
for (const file of subFiles) {
if (file.endsWith('.json')) {
files.push(join(fullPath, file));
}
}
}
}
}
catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
return files;
}
}
//# sourceMappingURL=manager.js.map