import { basename } from "node:path"; import { access } from "node:fs/promises"; import { clearAndRestart, exportSession, readChlsj } from "./client.js"; import { entryMillis, entryStartTime } from "./process.js"; import type { CacheState, CharlesEntry, Checkpoint } from "./types.js"; /** Live archive: numeric id → entry */ const archive = new Map(); /** Recording archive: "rec:file:id" → entry */ const archiveRec = new Map(); /** Timeline of harvest/load_recording events */ const checkpoints: Checkpoint[] = []; /** Max times.start millis already processed (live watermark) */ let maxSeenMillis = 0; /** Keyword unlock expiry map (lowercased keyword → epoch ms) */ const keywordAuth = new Map(); const cache: CacheState = { data: [], checkpoint_id: null, harvested_at: 0, total_in_session: 0, }; function nowHms(): string { return new Date().toLocaleTimeString("en-GB", { hour12: false }); } function getArchived(key: number | string): CharlesEntry | undefined { if (typeof key === "number") return archive.get(key); if (key.startsWith("rec:")) return archiveRec.get(key); const asNum = Number(key); if (!Number.isNaN(asNum) && String(asNum) === key) { return archive.get(asNum); } return archiveRec.get(key); } /** Stale-data hint when viewing latest checkpoint. */ export function dataHint(): string | null { if (checkpoints.length === 0) { return "No data yet. Call harvest_data() first."; } if (cache.checkpoint_id === checkpoints.length) { const age = (Date.now() - cache.harvested_at) / 1000; if (age > 180) { return `Data is ${Math.floor(age / 60)} minute(s) stale. Call harvest_data() for fresh traffic.`; } } return null; } export function getCache(): CacheState { return cache; } export function getCheckpoints(): Checkpoint[] { return checkpoints; } export function isKeywordAuthed(keyword: string): boolean { const exp = keywordAuth.get(keyword.toLowerCase()); return exp !== undefined && exp > Date.now(); } export function authorizeKeyword(keyword: string, ttlMs = 300_000): void { keywordAuth.set(keyword.toLowerCase(), Date.now() + ttlMs); } export function findEntry(entryId: string): CharlesEntry | undefined { const local = cache.data.find( (e) => String(e._mcp_id ?? e.id) === String(entryId), ); if (local) return local; return getArchived(entryId); } export async function harvestData( freshStart = false, ): Promise> { const allEntries = await exportSession(); const curMaxMillis = allEntries.reduce( (max, e) => Math.max(max, entryMillis(e)), maxSeenMillis, ); let newEntries: CharlesEntry[]; if (freshStart) { maxSeenMillis = curMaxMillis; newEntries = []; } else { newEntries = allEntries.filter((e) => { const ms = entryMillis(e); const id = e.id; const unseen = typeof id === "number" ? !archive.has(id) : true; return (ms > maxSeenMillis || ms === 0) && unseen; }); maxSeenMillis = curMaxMillis; for (const e of newEntries) { if (typeof e.id === "number") archive.set(e.id, e); } } const cleared = await clearAndRestart(); const cp: Checkpoint = { id: checkpoints.length + 1, source: "live", read_at: nowHms(), count: newEntries.length, entry_ids: newEntries.map((e) => e.id as number | string), is_reset: freshStart, start_time: newEntries[0] ? entryStartTime(newEntries[0]) || null : null, end_time: newEntries.length ? entryStartTime(newEntries[newEntries.length - 1]!) || null : null, }; checkpoints.push(cp); keywordAuth.clear(); cache.data = newEntries; cache.checkpoint_id = cp.id; cache.harvested_at = Date.now(); cache.total_in_session = allEntries.length; const result: Record = { checkpoint_id: cp.id, fresh_start: freshStart, new_entries: newEntries.length, total_archived: archive.size, total_in_session: allEntries.length, charles_cleared: cleared, }; if (freshStart) { result.hint = "Reset point recorded. Trigger the target action in the app, then call harvest_data() again for new traffic."; } return result; } export function listCheckpoints(): Record { const summary = checkpoints.map(({ entry_ids: _ids, ...rest }) => rest); return { total: checkpoints.length, current_checkpoint_id: cache.checkpoint_id, total_archived: archive.size, checkpoints: summary, }; } export function loadCheckpoint(checkpointId: number): Record { if (checkpoints.length === 0) { return { error: "NO_CHECKPOINTS", message: "No checkpoints yet. Call harvest_data() first.", }; } if (checkpointId < 1 || checkpointId > checkpoints.length) { return { error: "INVALID_ID", message: `checkpoint_id must be between 1 and ${checkpoints.length}.`, }; } const cp = checkpoints[checkpointId - 1]!; const entries = cp.is_reset ? [] : cp.entry_ids .map((id) => getArchived(id)) .filter((e): e is CharlesEntry => e !== undefined); keywordAuth.clear(); cache.data = entries; cache.checkpoint_id = checkpointId; return { checkpoint_id: checkpointId, read_at: cp.read_at, loaded: entries.length, start_time: cp.start_time, end_time: cp.end_time, is_reset: cp.is_reset, hint: "Switched to this time window. All filter tools now operate here. Call harvest_data() to return to the latest data.", }; } export async function loadRecording( filePath: string, ): Promise> { try { await access(filePath); } catch { return { error: "FILE_NOT_FOUND", file_path: filePath }; } const fname = basename(filePath); let entries: CharlesEntry[]; try { entries = await readChlsj(filePath); } catch (err) { return { error: "READ_FAILED", message: err instanceof Error ? err.message : String(err), }; } const recIds: string[] = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]!; const rawId = e.id !== undefined && e.id !== null ? e.id : i; const key = `rec:${fname}:${rawId}`; e._mcp_id = key; archiveRec.set(key, e); recIds.push(key); } const cp: Checkpoint = { id: checkpoints.length + 1, source: `recording:${fname}`, read_at: nowHms(), count: entries.length, entry_ids: recIds, is_reset: false, start_time: entries[0] ? entryStartTime(entries[0]) || null : null, end_time: entries.length ? entryStartTime(entries[entries.length - 1]!) || null : null, }; checkpoints.push(cp); keywordAuth.clear(); cache.data = entries; cache.checkpoint_id = cp.id; return { checkpoint_id: cp.id, file: fname, loaded: entries.length, start_time: cp.start_time, end_time: cp.end_time, total_archived: archive.size + archiveRec.size, }; }