src / tasks.ts
import { existsSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { join } from "path";
export type TaskStatus = "pending" | "in_progress" | "done" | "blocked";
export interface Task {
text: string;
status: TaskStatus;
note: string;
}
export const TASK_FILE_NAME = ".lmstudio-tasks.json";
/**
* Fallback store used when file writes are disabled. Keyed by workspace root so
* two chats pointed at different folders do not share a checklist.
*/
const memoryStore = new Map<string, Task[]>();
export class TaskStore {
private readonly filePath: string;
/**
* @param persist when false the checklist lives only in the plugin process,
* so the "allow writing files" toggle is never quietly bypassed.
*/
constructor(
private readonly root: string,
private readonly persist: boolean,
) {
this.filePath = join(root, TASK_FILE_NAME);
}
get storageDescription(): string {
return this.persist ? TASK_FILE_NAME : "plugin memory (not saved to disk)";
}
async load(): Promise<Task[]> {
if (!this.persist) {
return memoryStore.get(this.root) ?? [];
}
if (!existsSync(this.filePath)) return [];
try {
const parsed: unknown = JSON.parse(await readFile(this.filePath, "utf-8"));
if (!Array.isArray(parsed)) return [];
return parsed.filter(isTask);
} catch {
return [];
}
}
async save(tasks: Task[]): Promise<void> {
if (!this.persist) {
memoryStore.set(this.root, tasks);
return;
}
await writeFile(this.filePath, JSON.stringify(tasks, null, 2), "utf-8");
}
}
function isTask(value: unknown): value is Task {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.text === "string" &&
typeof candidate.status === "string" &&
["pending", "in_progress", "done", "blocked"].includes(candidate.status)
);
}
const STATUS_MARK: Record<TaskStatus, string> = {
pending: "[ ]",
in_progress: "[~]",
done: "[x]",
blocked: "[!]",
};
export function renderTasks(tasks: Task[]): string {
if (tasks.length === 0) {
return "The checklist is empty. Use set_tasks to create one.";
}
const lines = tasks.map((task, index) => {
const note = task.note.trim() === "" ? "" : ` -- ${task.note.trim()}`;
return `${index + 1}. ${STATUS_MARK[task.status]} ${task.text}${note}`;
});
const remaining = tasks.filter((task) => task.status !== "done").length;
lines.push("", `${tasks.length - remaining}/${tasks.length} done, ${remaining} remaining.`);
return lines.join("\n");
}
src / tasks.ts
import { existsSync } from "fs";
import { readFile, writeFile } from "fs/promises";
import { join } from "path";
export type TaskStatus = "pending" | "in_progress" | "done" | "blocked";
export interface Task {
text: string;
status: TaskStatus;
note: string;
}
export const TASK_FILE_NAME = ".lmstudio-tasks.json";
/**
* Fallback store used when file writes are disabled. Keyed by workspace root so
* two chats pointed at different folders do not share a checklist.
*/
const memoryStore = new Map<string, Task[]>();
export class TaskStore {
private readonly filePath: string;
/**
* @param persist when false the checklist lives only in the plugin process,
* so the "allow writing files" toggle is never quietly bypassed.
*/
constructor(
private readonly root: string,
private readonly persist: boolean,
) {
this.filePath = join(root, TASK_FILE_NAME);
}
get storageDescription(): string {
return this.persist ? TASK_FILE_NAME : "plugin memory (not saved to disk)";
}
async load(): Promise<Task[]> {
if (!this.persist) {
return memoryStore.get(this.root) ?? [];
}
if (!existsSync(this.filePath)) return [];
try {
const parsed: unknown = JSON.parse(await readFile(this.filePath, "utf-8"));
if (!Array.isArray(parsed)) return [];
return parsed.filter(isTask);
} catch {
return [];
}
}
async save(tasks: Task[]): Promise<void> {
if (!this.persist) {
memoryStore.set(this.root, tasks);
return;
}
await writeFile(this.filePath, JSON.stringify(tasks, null, 2), "utf-8");
}
}
function isTask(value: unknown): value is Task {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.text === "string" &&
typeof candidate.status === "string" &&
["pending", "in_progress", "done", "blocked"].includes(candidate.status)
);
}
const STATUS_MARK: Record<TaskStatus, string> = {
pending: "[ ]",
in_progress: "[~]",
done: "[x]",
blocked: "[!]",
};
export function renderTasks(tasks: Task[]): string {
if (tasks.length === 0) {
return "The checklist is empty. Use set_tasks to create one.";
}
const lines = tasks.map((task, index) => {
const note = task.note.trim() === "" ? "" : ` -- ${task.note.trim()}`;
return `${index + 1}. ${STATUS_MARK[task.status]} ${task.text}${note}`;
});
const remaining = tasks.filter((task) => task.status !== "done").length;
lines.push("", `${tasks.length - remaining}/${tasks.length} done, ${remaining} remaining.`);
return lines.join("\n");
}