src / cleanup.ts
src / cleanup.ts
// Cleanup after deleted chats. Runs when the plugin starts, which LM Studio does at launch and
// whenever plugins are switched in a chat.
//
// A chat is gone when its file no longer exists in LM Studio's conversations folder (checked by
// name only; the files are never opened). Its folder here (state and recall index) is deleted at
// once, since it only mirrors the chat. A project whose every chat is gone is marked orphaned;
// after a grace period it moves to the trash, and the trash is emptied after a retention period.
// If the conversations folder is missing, unreadable or empty, nothing is deleted.
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { commit, listProjects, loadLedger, loadMeta, saveMeta } from "./ledger";
import { appendLine, chatsDir, ensureDir, projectDir, rootDir, trashDir } from "./store";
export const ORPHAN_GRACE_DAYS = 7;
export const TRASH_RETENTION_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
export function conversationsDir(): string {
return process.env.THROUGHLINE_CONVERSATIONS || path.join(os.homedir(), ".lmstudio", "conversations");
}
// Ids of chats that still exist, from file names only. Null when the folder cannot be trusted.
export function existingChatIds(): Set<string> | null {
const ids = new Set<string>();
const walk = (dir: string, depth: number) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory() && depth < 3) walk(path.join(dir, e.name), depth + 1);
const m = /^(.+)\.conversation\.json$/.exec(e.name);
if (e.isFile() && m) ids.add(m[1]);
}
};
try {
walk(conversationsDir(), 0);
} catch {
return null;
}
return ids.size ? ids : null;
}
export interface CleanupReport {
skipped?: string;
chatsRemoved: string[];
orphaned: string[];
trashed: string[];
purged: string[];
}
export function cleanup(now = Date.now()): CleanupReport {
const report: CleanupReport = { chatsRemoved: [], orphaned: [], trashed: [], purged: [] };
const ids = existingChatIds();
if (!ids) {
report.skipped = "conversations folder missing, unreadable or empty";
return report;
}
// Chats that no longer exist: remove their folders here.
let chatDirs: string[] = [];
try { chatDirs = fs.readdirSync(chatsDir()); } catch { /* none yet */ }
for (const id of chatDirs) {
if (ids.has(id)) continue;
fs.rmSync(path.join(chatsDir(), id), { recursive: true, force: true });
report.chatsRemoved.push(id);
}
// Projects: drop deleted chats, mark orphans, move expired orphans to the trash.
for (const slug of listProjects()) {
const l = loadLedger(slug);
if (!l) continue;
const gone = l.chats.filter((c) => !ids.has(c));
const meta = loadMeta(slug);
if (gone.length) {
l.chats = l.chats.filter((c) => ids.has(c));
commit(l, gone.map((c) => ({ actor: "plugin" as const, op: "chat_deleted", chat: c })));
if (!l.chats.length) {
const m = loadMeta(slug);
m.orphanedSince = new Date(now).toISOString();
saveMeta(slug, m);
report.orphaned.push(slug);
}
continue;
}
if (l.chats.length && meta.orphanedSince) {
delete meta.orphanedSince;
saveMeta(slug, meta);
} else if (!l.chats.length && meta.orphanedSince && now - Date.parse(meta.orphanedSince) > ORPHAN_GRACE_DAYS * DAY) {
ensureDir(trashDir());
fs.renameSync(projectDir(slug), path.join(trashDir(), `${slug}-${now}`));
report.trashed.push(slug);
}
}
// Trash older than the retention period is deleted for good.
let trash: string[] = [];
try { trash = fs.readdirSync(trashDir()); } catch { /* empty */ }
for (const t of trash) {
const at = Number(/-(\d{12,})$/.exec(t)?.[1]);
if (at && now - at > TRASH_RETENTION_DAYS * DAY) {
fs.rmSync(path.join(trashDir(), t), { recursive: true, force: true });
report.purged.push(t);
}
}
if (report.chatsRemoved.length || report.orphaned.length || report.trashed.length || report.purged.length) {
try {
appendLine(path.join(rootDir(), "cleanup.log"), JSON.stringify({ ts: new Date(now).toISOString(), ...report }));
} catch {
// Logging is optional.
}
}
return report;
}
// Cleanup after deleted chats. Runs when the plugin starts, which LM Studio does at launch and
// whenever plugins are switched in a chat.
//
// A chat is gone when its file no longer exists in LM Studio's conversations folder (checked by
// name only; the files are never opened). Its folder here (state and recall index) is deleted at
// once, since it only mirrors the chat. A project whose every chat is gone is marked orphaned;
// after a grace period it moves to the trash, and the trash is emptied after a retention period.
// If the conversations folder is missing, unreadable or empty, nothing is deleted.
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { commit, listProjects, loadLedger, loadMeta, saveMeta } from "./ledger";
import { appendLine, chatsDir, ensureDir, projectDir, rootDir, trashDir } from "./store";
export const ORPHAN_GRACE_DAYS = 7;
export const TRASH_RETENTION_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
export function conversationsDir(): string {
return process.env.THROUGHLINE_CONVERSATIONS || path.join(os.homedir(), ".lmstudio", "conversations");
}
// Ids of chats that still exist, from file names only. Null when the folder cannot be trusted.
export function existingChatIds(): Set<string> | null {
const ids = new Set<string>();
const walk = (dir: string, depth: number) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory() && depth < 3) walk(path.join(dir, e.name), depth + 1);
const m = /^(.+)\.conversation\.json$/.exec(e.name);
if (e.isFile() && m) ids.add(m[1]);
}
};
try {
walk(conversationsDir(), 0);
} catch {
return null;
}
return ids.size ? ids : null;
}
export interface CleanupReport {
skipped?: string;
chatsRemoved: string[];
orphaned: string[];
trashed: string[];
purged: string[];
}
export function cleanup(now = Date.now()): CleanupReport {
const report: CleanupReport = { chatsRemoved: [], orphaned: [], trashed: [], purged: [] };
const ids = existingChatIds();
if (!ids) {
report.skipped = "conversations folder missing, unreadable or empty";
return report;
}
// Chats that no longer exist: remove their folders here.
let chatDirs: string[] = [];
try { chatDirs = fs.readdirSync(chatsDir()); } catch { /* none yet */ }
for (const id of chatDirs) {
if (ids.has(id)) continue;
fs.rmSync(path.join(chatsDir(), id), { recursive: true, force: true });
report.chatsRemoved.push(id);
}
// Projects: drop deleted chats, mark orphans, move expired orphans to the trash.
for (const slug of listProjects()) {
const l = loadLedger(slug);
if (!l) continue;
const gone = l.chats.filter((c) => !ids.has(c));
const meta = loadMeta(slug);
if (gone.length) {
l.chats = l.chats.filter((c) => ids.has(c));
commit(l, gone.map((c) => ({ actor: "plugin" as const, op: "chat_deleted", chat: c })));
if (!l.chats.length) {
const m = loadMeta(slug);
m.orphanedSince = new Date(now).toISOString();
saveMeta(slug, m);
report.orphaned.push(slug);
}
continue;
}
if (l.chats.length && meta.orphanedSince) {
delete meta.orphanedSince;
saveMeta(slug, meta);
} else if (!l.chats.length && meta.orphanedSince && now - Date.parse(meta.orphanedSince) > ORPHAN_GRACE_DAYS * DAY) {
ensureDir(trashDir());
fs.renameSync(projectDir(slug), path.join(trashDir(), `${slug}-${now}`));
report.trashed.push(slug);
}
}
// Trash older than the retention period is deleted for good.
let trash: string[] = [];
try { trash = fs.readdirSync(trashDir()); } catch { /* empty */ }
for (const t of trash) {
const at = Number(/-(\d{12,})$/.exec(t)?.[1]);
if (at && now - at > TRASH_RETENTION_DAYS * DAY) {
fs.rmSync(path.join(trashDir(), t), { recursive: true, force: true });
report.purged.push(t);
}
}
if (report.chatsRemoved.length || report.orphaned.length || report.trashed.length || report.purged.length) {
try {
appendLine(path.join(rootDir(), "cleanup.log"), JSON.stringify({ ts: new Date(now).toISOString(), ...report }));
} catch {
// Logging is optional.
}
}
return report;
}