src / stateManager.ts
src / stateManager.ts
/**
* Workspace + persisted plugin state.
*
* Resolution when config is empty:
* → LM Studio's ctl.getWorkingDirectory() (standard per-chat workspace)
* → last-resort: os.tmpdir()/lmstudio-plugin-workspace
*
* Hot-swap:
* - Config path change → next toolsProvider call resets to new root
* - change_directory("relative") → stay inside current workspaceRoot
* - change_directory("/absolute/existing/path") → hot-swap: that path
* becomes the new workspaceRoot + CWD (for iterative multi-project work)
*
* Status always exposes currentWorkingDirectory + workspaceRoot.
*
* Memory enforcement:
* - messageCount is incremented on every tool call and persisted to disk
* - When messageCount exceeds contextCompressThreshold, context compression
* is automatically triggered (enforced by toolsProvider)
* - workspace memory.md entries are synced across sessions
* - subAgentDocsInjected flag prevents duplicate injections across reloads
*/
import { readFile, writeFile, mkdir, realpath, stat } from "fs/promises";
import { join, isAbsolute, resolve, normalize, relative } from "path";
import * as os from "os";
import { homedir } from "os";
import { createHash } from "crypto";
import { isUnrestrictedMode } from "./pathSecurity";
const CONFIG_FILE_NAME = ".plugin_state.json";
const STATE_DIR = join(homedir(), ".timbers-toolbox");
const LAST_RESORT_DIR = join(os.tmpdir(), "lmstudio-plugin-workspace");
function stateKey(configuredWorkspacePath: string, lmStudioWorkingDirectory?: string | null): string {
return createHash("sha256")
.update(`${configuredWorkspacePath}\n${lmStudioWorkingDirectory ?? ""}`)
.digest("hex")
.slice(0, 24);
}
// Default context compression threshold (message count)
export const DEFAULT_CONTEXT_COMPRESS_THRESHOLD = 50;
export interface PluginState {
/** Active working directory (absolute). Shown in status. */
currentWorkingDirectory: string;
/** Security boundary (absolute). Shown in status. */
workspaceRoot: string;
/** Raw config string used to detect hot-swap of the config field. */
configWorkspacePath: string;
/** Per-chat working directory this state belongs to (prevents cross-chat leakage). */
lmStudioCwd: string;
messageCount: number;
dontAskToCompress: boolean;
subAgentDocsInjected: boolean;
uiLanguageOverride: string;
}
function expandEnvVars(raw: string): string {
return raw
.replace(/%([^%]+)%/g, (_m, name: string) => process.env[name] ?? `%${name}%`)
.replace(/\$\{([^}]+)\}/g, (_m, name: string) => process.env[name] ?? `\${${name}}`)
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name: string) => process.env[name] ?? `$${name}`)
.replace(/^~(?=$|[\\/])/, homedir());
}
export function resolveWorkspaceRoot(
configuredWorkspacePath: string | undefined | null,
lmStudioWorkingDirectory?: string | null,
): { root: string; source: "config" | "lmstudio" | "fallback" } {
const raw = (configuredWorkspacePath ?? "").trim();
if (raw) {
const expanded = expandEnvVars(raw);
const base = lmStudioWorkingDirectory
? resolve(lmStudioWorkingDirectory)
: process.cwd();
const root = isAbsolute(expanded) ? resolve(expanded) : resolve(base, expanded);
return { root: normalize(root), source: "config" };
}
if (lmStudioWorkingDirectory && lmStudioWorkingDirectory.trim()) {
return {
root: normalize(resolve(lmStudioWorkingDirectory)),
source: "lmstudio",
};
}
return { root: normalize(LAST_RESORT_DIR), source: "fallback" };
}
/**
* The SDK only exposes the per-chat directory when the controller is attached
* to a workspace. During provider setup, LM Studio may expose that directory
* as the plugin process CWD instead.
*/
export function getRuntimeWorkingDirectory(
controller: { getWorkingDirectory?: () => string },
): string | null {
try {
const workingDirectory =
typeof controller.getWorkingDirectory === "function"
? controller.getWorkingDirectory()
: "";
if (workingDirectory && workingDirectory.trim()) {
return workingDirectory;
}
} catch {
// The SDK throws when the provider session has no attached workspace.
}
const processWorkingDirectory = process.cwd();
return processWorkingDirectory.trim() ? processWorkingDirectory : null;
}
export async function assertInsideWorkspace(
workspaceRoot: string,
candidatePath: string,
): Promise<string> {
// ⚠️ Master switch: no sandbox — resolve freely, any drive.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(candidatePath)) throw new Error("Path contains illegal control characters.");
const free = normalize(
isAbsolute(candidatePath) || /^[A-Za-z]:[\\/]/.test(candidatePath)
? resolve(candidatePath)
: resolve(normalize(resolve(workspaceRoot)), candidatePath),
);
try {
return await realpath(free).catch(() => free);
} catch {
return free;
}
}
const normalizedRoot = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(normalizedRoot, candidatePath));
const rel = relative(normalizedRoot, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Access Denied: Path '${candidatePath}' is outside the workspace root.`,
);
}
try {
const realRoot = await realpath(normalizedRoot);
const realCandidate = await realpath(resolved).catch(() => resolved);
const relReal = relative(realRoot, realCandidate);
if (relReal.startsWith("..") || isAbsolute(relReal)) {
throw new Error(
`Access Denied: Path '${candidatePath}' resolves outside the workspace (symlink).`,
);
}
return realCandidate;
} catch (e) {
if (e instanceof Error && e.message.startsWith("Access Denied")) throw e;
return resolved;
}
}
export function assertInsideWorkspaceSync(
workspaceRoot: string,
candidatePath: string,
): string {
// ⚠️ Master switch: no sandbox — resolve freely, any drive.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(candidatePath)) throw new Error("Path contains illegal control characters.");
if (isAbsolute(candidatePath) || /^[A-Za-z]:[\\/]/.test(candidatePath)) {
return normalize(resolve(candidatePath));
}
return normalize(resolve(normalize(resolve(workspaceRoot)), candidatePath));
}
const normalizedRoot = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(normalizedRoot, candidatePath));
const rel = relative(normalizedRoot, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Access Denied: Path '${candidatePath}' is outside the workspace root.`,
);
}
if (process.platform === "win32") {
const rootDrive = normalizedRoot.slice(0, 2).toUpperCase();
const candDrive = resolved.slice(0, 2).toUpperCase();
if (/^[A-Z]:$/.test(rootDrive) && /^[A-Z]:$/.test(candDrive) && rootDrive !== candDrive) {
throw new Error(
`Access Denied: Path '${candidatePath}' is on a different drive.`,
);
}
}
return resolved;
}
async function loadRawState(key: string): Promise<Partial<PluginState> | null> {
try {
const statePath = join(STATE_DIR, `${CONFIG_FILE_NAME}.${key}.json`);
const content = await readFile(statePath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
}
export async function getPersistedState(
configuredWorkspacePath?: string,
lmStudioWorkingDirectory?: string | null,
): Promise<PluginState> {
const { root: resolvedRoot, source } = resolveWorkspaceRoot(
configuredWorkspacePath,
lmStudioWorkingDirectory,
);
const configKey = (configuredWorkspacePath ?? "").trim();
const raw = await loadRawState(stateKey(configKey, lmStudioWorkingDirectory));
// Config field changed → reset to new root (hot-swap from settings UI)
const configChanged =
raw?.configWorkspacePath !== undefined &&
raw.configWorkspacePath !== configKey;
// Only restore a persisted workspace if it belongs to the SAME chat
// (same per-chat working directory). The state file is global, so without
// this guard a hot-swap in one chat would leak into every other chat.
const sameChat =
raw?.lmStudioCwd !== undefined &&
raw.lmStudioCwd === (lmStudioWorkingDirectory ?? "");
let currentWorkingDirectory = resolvedRoot;
let workspaceRoot = resolvedRoot;
if (!configChanged && sameChat && raw?.workspaceRoot && raw?.currentWorkingDirectory) {
// Keep a previously hot-swapped root (from change_directory to an absolute path)
// only when the config field itself did not change.
try {
workspaceRoot = normalize(resolve(raw.workspaceRoot));
currentWorkingDirectory = assertInsideWorkspaceSync(
workspaceRoot,
raw.currentWorkingDirectory,
);
} catch {
workspaceRoot = resolvedRoot;
currentWorkingDirectory = resolvedRoot;
}
}
if (configChanged) {
workspaceRoot = resolvedRoot;
currentWorkingDirectory = resolvedRoot;
}
const state: PluginState = {
currentWorkingDirectory,
workspaceRoot,
configWorkspacePath: configKey,
lmStudioCwd: lmStudioWorkingDirectory ?? "",
messageCount: raw?.messageCount ?? 0,
dontAskToCompress: raw?.dontAskToCompress ?? false,
subAgentDocsInjected: raw?.subAgentDocsInjected ?? false,
uiLanguageOverride: raw?.uiLanguageOverride ?? "auto",
};
if (source === "fallback") {
console.warn(
`[stateManager] No config workspace and no LM Studio CWD – using temp sandbox: ${resolvedRoot}`,
);
}
return state;
}
export async function savePersistedState(state: PluginState): Promise<void> {
try {
await mkdir(STATE_DIR, { recursive: true });
const key = stateKey(state.configWorkspacePath, state.lmStudioCwd);
const statePath = join(STATE_DIR, `${CONFIG_FILE_NAME}.${key}.json`);
const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
const toSave = {
currentWorkingDirectory: state.currentWorkingDirectory,
workspaceRoot: state.workspaceRoot,
configWorkspacePath: state.configWorkspacePath,
lmStudioCwd: state.lmStudioCwd,
messageCount: state.messageCount,
dontAskToCompress: state.dontAskToCompress,
subAgentDocsInjected: state.subAgentDocsInjected,
uiLanguageOverride: state.uiLanguageOverride,
};
await writeFile(temporaryPath, JSON.stringify(toSave, null, 2), "utf-8");
const { rename } = await import("fs/promises");
await rename(temporaryPath, statePath);
} catch (error) {
console.error("Failed to save plugin state:", error);
}
}
/**
* Increment the message count and persist it. This is called after every tool call
* to enforce context compression thresholds and maintain accurate session tracking.
*/
export async function incrementMessageCount(state: PluginState): Promise<void> {
state.messageCount += 1;
await savePersistedState(state);
}
/**
* Check if context compression should be triggered based on the message count threshold.
* Returns true when compression is needed and not already suppressed.
*/
export function shouldCompressContext(
state: PluginState,
customThreshold?: number,
): boolean {
const threshold = customThreshold ?? DEFAULT_CONTEXT_COMPRESS_THRESHOLD;
return !state.dontAskToCompress && state.messageCount >= threshold;
}
/**
* Ensure the memory.md file exists and is initialized with a header if empty.
* This enforces that long-term memory has a persistent backing store across sessions.
*/
export async function ensureMemoryFileExists(workspaceRoot: string): Promise<string> {
const memoryPath = join(workspaceRoot, "memory.md");
try {
await stat(memoryPath);
return memoryPath;
} catch {
// File doesn't exist — create it with a header
await mkdir(workspaceRoot, { recursive: true });
await writeFile(
memoryPath,
"# Long-Term Memory\n\nThis file stores persistent facts and preferences across sessions.\n",
"utf-8",
);
return memoryPath;
}
}
/**
* Sync the in-memory knowledge base with the persisted memory.md file.
* This ensures that any facts saved during this session are written to disk,
* and that new facts from other sessions are loaded on next access.
*/
export async function syncMemoryState(workspaceRoot: string): Promise<{ synced: boolean; entriesCount: number }> {
const memoryPath = join(workspaceRoot, "memory.md");
try {
const content = await readFile(memoryPath, "utf-8");
// Count entries (lines starting with "- [")
const entries = content.split("\n").filter(line => line.trim().startsWith("- ["));
return { synced: true, entriesCount: entries.length };
} catch {
// memory.md doesn't exist yet — create it
await ensureMemoryFileExists(workspaceRoot);
return { synced: false, entriesCount: 0 };
}
}
export async function ensureWorkspaceExists(path: string): Promise<void> {
try {
await mkdir(path, { recursive: true });
} catch (error) {
console.error(`Failed to create/access directory ${path}`, error);
}
}
export type ChangeDirectoryResult = {
previous_directory: string;
current_directory: string;
workspace_root: string;
swapped_workspace: boolean;
message: string;
};
/**
* Change directory with optional workspace hot-swap.
*
* - Relative path (or absolute path still inside root):
* navigate within the current workspaceRoot.
* - Absolute path outside root that exists and is a directory:
* hot-swap — that path becomes the new workspaceRoot AND CWD.
* This is how "cd D:\AGI_Copy" / "cd E:\AI\new_build" works mid-session.
*/
export async function changeWorkingDirectory(
state: PluginState,
requested: string,
): Promise<ChangeDirectoryResult> {
const previous = state.currentWorkingDirectory;
const expanded = expandEnvVars(requested.trim());
if (!expanded) {
throw new Error("Directory path is empty.");
}
// ⚠️ Master switch: cd anywhere, no workspace boundary, no hot-swap needed.
// Any absolute path (any drive) or relative path just becomes the new CWD.
// Workspace root follows so subsequent relative paths resolve from there.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(expanded)) throw new Error("Path contains illegal control characters.");
const looksAbs = isAbsolute(expanded) || /^[A-Za-z]:[\\/]/.test(expanded);
const target = normalize(
looksAbs ? resolve(expanded) : resolve(state.currentWorkingDirectory, expanded),
);
let realTarget = target;
try {
realTarget = await realpath(target).catch(() => target);
} catch {
realTarget = target;
}
const stats = await stat(realTarget).catch(() => null);
if (!stats || !stats.isDirectory()) {
throw new Error(`Cannot change directory: '${realTarget}' does not exist or is not a directory.`);
}
state.currentWorkingDirectory = realTarget;
state.workspaceRoot = realTarget;
await savePersistedState(state);
return {
previous_directory: previous,
current_directory: state.currentWorkingDirectory,
workspace_root: state.workspaceRoot,
swapped_workspace: true,
message: `Changed directory to ${state.currentWorkingDirectory} (UNRESTRICTED MODE — workspace root follows).`,
};
}
const looksAbsolute =
isAbsolute(expanded) || /^[A-Za-z]:[\\/]/.test(expanded);
let target = looksAbsolute
? normalize(resolve(expanded))
: normalize(resolve(state.currentWorkingDirectory, expanded));
// Does this target already live inside the current root?
let insideRoot = false;
try {
assertInsideWorkspaceSync(state.workspaceRoot, target);
insideRoot = true;
} catch {
insideRoot = false;
}
let swapped = false;
if (!insideRoot) {
// Hot-swap only for absolute paths that exist as directories
if (!looksAbsolute) {
throw new Error(
`Access Denied: '${requested}' is outside the workspace. ` +
`Use an absolute path to switch workspaces (e.g. change_directory("D:\\\\projects\\\\app")).`,
);
}
const stats = await stat(target).catch(() => null);
if (!stats || !stats.isDirectory()) {
throw new Error(
`Cannot switch workspace: '${target}' does not exist or is not a directory.`,
);
}
// New workspace root
state.workspaceRoot = target;
state.currentWorkingDirectory = target;
swapped = true;
} else {
const safe = await assertInsideWorkspace(state.workspaceRoot, target);
const stats = await stat(safe);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${safe}`);
}
state.currentWorkingDirectory = safe;
}
await savePersistedState(state);
return {
previous_directory: previous,
current_directory: state.currentWorkingDirectory,
workspace_root: state.workspaceRoot,
swapped_workspace: swapped,
message: `Changed directory to ${state.currentWorkingDirectory}${swapped ? ' (workspace root changed)' : ''}.`,
};
}
/**
* Format the current working directory status for logging.
*/
export function formatCwdStatus(state: PluginState): string {
return `[Timber's Toolbox] CWD: ${state.currentWorkingDirectory} | Workspace: ${state.workspaceRoot}`;
}/**
* Workspace + persisted plugin state.
*
* Resolution when config is empty:
* → LM Studio's ctl.getWorkingDirectory() (standard per-chat workspace)
* → last-resort: os.tmpdir()/lmstudio-plugin-workspace
*
* Hot-swap:
* - Config path change → next toolsProvider call resets to new root
* - change_directory("relative") → stay inside current workspaceRoot
* - change_directory("/absolute/existing/path") → hot-swap: that path
* becomes the new workspaceRoot + CWD (for iterative multi-project work)
*
* Status always exposes currentWorkingDirectory + workspaceRoot.
*
* Memory enforcement:
* - messageCount is incremented on every tool call and persisted to disk
* - When messageCount exceeds contextCompressThreshold, context compression
* is automatically triggered (enforced by toolsProvider)
* - workspace memory.md entries are synced across sessions
* - subAgentDocsInjected flag prevents duplicate injections across reloads
*/
import { readFile, writeFile, mkdir, realpath, stat } from "fs/promises";
import { join, isAbsolute, resolve, normalize, relative } from "path";
import * as os from "os";
import { homedir } from "os";
import { createHash } from "crypto";
import { isUnrestrictedMode } from "./pathSecurity";
const CONFIG_FILE_NAME = ".plugin_state.json";
const STATE_DIR = join(homedir(), ".timbers-toolbox");
const LAST_RESORT_DIR = join(os.tmpdir(), "lmstudio-plugin-workspace");
function stateKey(configuredWorkspacePath: string, lmStudioWorkingDirectory?: string | null): string {
return createHash("sha256")
.update(`${configuredWorkspacePath}\n${lmStudioWorkingDirectory ?? ""}`)
.digest("hex")
.slice(0, 24);
}
// Default context compression threshold (message count)
export const DEFAULT_CONTEXT_COMPRESS_THRESHOLD = 50;
export interface PluginState {
/** Active working directory (absolute). Shown in status. */
currentWorkingDirectory: string;
/** Security boundary (absolute). Shown in status. */
workspaceRoot: string;
/** Raw config string used to detect hot-swap of the config field. */
configWorkspacePath: string;
/** Per-chat working directory this state belongs to (prevents cross-chat leakage). */
lmStudioCwd: string;
messageCount: number;
dontAskToCompress: boolean;
subAgentDocsInjected: boolean;
uiLanguageOverride: string;
}
function expandEnvVars(raw: string): string {
return raw
.replace(/%([^%]+)%/g, (_m, name: string) => process.env[name] ?? `%${name}%`)
.replace(/\$\{([^}]+)\}/g, (_m, name: string) => process.env[name] ?? `\${${name}}`)
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name: string) => process.env[name] ?? `$${name}`)
.replace(/^~(?=$|[\\/])/, homedir());
}
export function resolveWorkspaceRoot(
configuredWorkspacePath: string | undefined | null,
lmStudioWorkingDirectory?: string | null,
): { root: string; source: "config" | "lmstudio" | "fallback" } {
const raw = (configuredWorkspacePath ?? "").trim();
if (raw) {
const expanded = expandEnvVars(raw);
const base = lmStudioWorkingDirectory
? resolve(lmStudioWorkingDirectory)
: process.cwd();
const root = isAbsolute(expanded) ? resolve(expanded) : resolve(base, expanded);
return { root: normalize(root), source: "config" };
}
if (lmStudioWorkingDirectory && lmStudioWorkingDirectory.trim()) {
return {
root: normalize(resolve(lmStudioWorkingDirectory)),
source: "lmstudio",
};
}
return { root: normalize(LAST_RESORT_DIR), source: "fallback" };
}
/**
* The SDK only exposes the per-chat directory when the controller is attached
* to a workspace. During provider setup, LM Studio may expose that directory
* as the plugin process CWD instead.
*/
export function getRuntimeWorkingDirectory(
controller: { getWorkingDirectory?: () => string },
): string | null {
try {
const workingDirectory =
typeof controller.getWorkingDirectory === "function"
? controller.getWorkingDirectory()
: "";
if (workingDirectory && workingDirectory.trim()) {
return workingDirectory;
}
} catch {
// The SDK throws when the provider session has no attached workspace.
}
const processWorkingDirectory = process.cwd();
return processWorkingDirectory.trim() ? processWorkingDirectory : null;
}
export async function assertInsideWorkspace(
workspaceRoot: string,
candidatePath: string,
): Promise<string> {
// ⚠️ Master switch: no sandbox — resolve freely, any drive.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(candidatePath)) throw new Error("Path contains illegal control characters.");
const free = normalize(
isAbsolute(candidatePath) || /^[A-Za-z]:[\\/]/.test(candidatePath)
? resolve(candidatePath)
: resolve(normalize(resolve(workspaceRoot)), candidatePath),
);
try {
return await realpath(free).catch(() => free);
} catch {
return free;
}
}
const normalizedRoot = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(normalizedRoot, candidatePath));
const rel = relative(normalizedRoot, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Access Denied: Path '${candidatePath}' is outside the workspace root.`,
);
}
try {
const realRoot = await realpath(normalizedRoot);
const realCandidate = await realpath(resolved).catch(() => resolved);
const relReal = relative(realRoot, realCandidate);
if (relReal.startsWith("..") || isAbsolute(relReal)) {
throw new Error(
`Access Denied: Path '${candidatePath}' resolves outside the workspace (symlink).`,
);
}
return realCandidate;
} catch (e) {
if (e instanceof Error && e.message.startsWith("Access Denied")) throw e;
return resolved;
}
}
export function assertInsideWorkspaceSync(
workspaceRoot: string,
candidatePath: string,
): string {
// ⚠️ Master switch: no sandbox — resolve freely, any drive.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(candidatePath)) throw new Error("Path contains illegal control characters.");
if (isAbsolute(candidatePath) || /^[A-Za-z]:[\\/]/.test(candidatePath)) {
return normalize(resolve(candidatePath));
}
return normalize(resolve(normalize(resolve(workspaceRoot)), candidatePath));
}
const normalizedRoot = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(normalizedRoot, candidatePath));
const rel = relative(normalizedRoot, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Access Denied: Path '${candidatePath}' is outside the workspace root.`,
);
}
if (process.platform === "win32") {
const rootDrive = normalizedRoot.slice(0, 2).toUpperCase();
const candDrive = resolved.slice(0, 2).toUpperCase();
if (/^[A-Z]:$/.test(rootDrive) && /^[A-Z]:$/.test(candDrive) && rootDrive !== candDrive) {
throw new Error(
`Access Denied: Path '${candidatePath}' is on a different drive.`,
);
}
}
return resolved;
}
async function loadRawState(key: string): Promise<Partial<PluginState> | null> {
try {
const statePath = join(STATE_DIR, `${CONFIG_FILE_NAME}.${key}.json`);
const content = await readFile(statePath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
}
export async function getPersistedState(
configuredWorkspacePath?: string,
lmStudioWorkingDirectory?: string | null,
): Promise<PluginState> {
const { root: resolvedRoot, source } = resolveWorkspaceRoot(
configuredWorkspacePath,
lmStudioWorkingDirectory,
);
const configKey = (configuredWorkspacePath ?? "").trim();
const raw = await loadRawState(stateKey(configKey, lmStudioWorkingDirectory));
// Config field changed → reset to new root (hot-swap from settings UI)
const configChanged =
raw?.configWorkspacePath !== undefined &&
raw.configWorkspacePath !== configKey;
// Only restore a persisted workspace if it belongs to the SAME chat
// (same per-chat working directory). The state file is global, so without
// this guard a hot-swap in one chat would leak into every other chat.
const sameChat =
raw?.lmStudioCwd !== undefined &&
raw.lmStudioCwd === (lmStudioWorkingDirectory ?? "");
let currentWorkingDirectory = resolvedRoot;
let workspaceRoot = resolvedRoot;
if (!configChanged && sameChat && raw?.workspaceRoot && raw?.currentWorkingDirectory) {
// Keep a previously hot-swapped root (from change_directory to an absolute path)
// only when the config field itself did not change.
try {
workspaceRoot = normalize(resolve(raw.workspaceRoot));
currentWorkingDirectory = assertInsideWorkspaceSync(
workspaceRoot,
raw.currentWorkingDirectory,
);
} catch {
workspaceRoot = resolvedRoot;
currentWorkingDirectory = resolvedRoot;
}
}
if (configChanged) {
workspaceRoot = resolvedRoot;
currentWorkingDirectory = resolvedRoot;
}
const state: PluginState = {
currentWorkingDirectory,
workspaceRoot,
configWorkspacePath: configKey,
lmStudioCwd: lmStudioWorkingDirectory ?? "",
messageCount: raw?.messageCount ?? 0,
dontAskToCompress: raw?.dontAskToCompress ?? false,
subAgentDocsInjected: raw?.subAgentDocsInjected ?? false,
uiLanguageOverride: raw?.uiLanguageOverride ?? "auto",
};
if (source === "fallback") {
console.warn(
`[stateManager] No config workspace and no LM Studio CWD – using temp sandbox: ${resolvedRoot}`,
);
}
return state;
}
export async function savePersistedState(state: PluginState): Promise<void> {
try {
await mkdir(STATE_DIR, { recursive: true });
const key = stateKey(state.configWorkspacePath, state.lmStudioCwd);
const statePath = join(STATE_DIR, `${CONFIG_FILE_NAME}.${key}.json`);
const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
const toSave = {
currentWorkingDirectory: state.currentWorkingDirectory,
workspaceRoot: state.workspaceRoot,
configWorkspacePath: state.configWorkspacePath,
lmStudioCwd: state.lmStudioCwd,
messageCount: state.messageCount,
dontAskToCompress: state.dontAskToCompress,
subAgentDocsInjected: state.subAgentDocsInjected,
uiLanguageOverride: state.uiLanguageOverride,
};
await writeFile(temporaryPath, JSON.stringify(toSave, null, 2), "utf-8");
const { rename } = await import("fs/promises");
await rename(temporaryPath, statePath);
} catch (error) {
console.error("Failed to save plugin state:", error);
}
}
/**
* Increment the message count and persist it. This is called after every tool call
* to enforce context compression thresholds and maintain accurate session tracking.
*/
export async function incrementMessageCount(state: PluginState): Promise<void> {
state.messageCount += 1;
await savePersistedState(state);
}
/**
* Check if context compression should be triggered based on the message count threshold.
* Returns true when compression is needed and not already suppressed.
*/
export function shouldCompressContext(
state: PluginState,
customThreshold?: number,
): boolean {
const threshold = customThreshold ?? DEFAULT_CONTEXT_COMPRESS_THRESHOLD;
return !state.dontAskToCompress && state.messageCount >= threshold;
}
/**
* Ensure the memory.md file exists and is initialized with a header if empty.
* This enforces that long-term memory has a persistent backing store across sessions.
*/
export async function ensureMemoryFileExists(workspaceRoot: string): Promise<string> {
const memoryPath = join(workspaceRoot, "memory.md");
try {
await stat(memoryPath);
return memoryPath;
} catch {
// File doesn't exist — create it with a header
await mkdir(workspaceRoot, { recursive: true });
await writeFile(
memoryPath,
"# Long-Term Memory\n\nThis file stores persistent facts and preferences across sessions.\n",
"utf-8",
);
return memoryPath;
}
}
/**
* Sync the in-memory knowledge base with the persisted memory.md file.
* This ensures that any facts saved during this session are written to disk,
* and that new facts from other sessions are loaded on next access.
*/
export async function syncMemoryState(workspaceRoot: string): Promise<{ synced: boolean; entriesCount: number }> {
const memoryPath = join(workspaceRoot, "memory.md");
try {
const content = await readFile(memoryPath, "utf-8");
// Count entries (lines starting with "- [")
const entries = content.split("\n").filter(line => line.trim().startsWith("- ["));
return { synced: true, entriesCount: entries.length };
} catch {
// memory.md doesn't exist yet — create it
await ensureMemoryFileExists(workspaceRoot);
return { synced: false, entriesCount: 0 };
}
}
export async function ensureWorkspaceExists(path: string): Promise<void> {
try {
await mkdir(path, { recursive: true });
} catch (error) {
console.error(`Failed to create/access directory ${path}`, error);
}
}
export type ChangeDirectoryResult = {
previous_directory: string;
current_directory: string;
workspace_root: string;
swapped_workspace: boolean;
message: string;
};
/**
* Change directory with optional workspace hot-swap.
*
* - Relative path (or absolute path still inside root):
* navigate within the current workspaceRoot.
* - Absolute path outside root that exists and is a directory:
* hot-swap — that path becomes the new workspaceRoot AND CWD.
* This is how "cd D:\AGI_Copy" / "cd E:\AI\new_build" works mid-session.
*/
export async function changeWorkingDirectory(
state: PluginState,
requested: string,
): Promise<ChangeDirectoryResult> {
const previous = state.currentWorkingDirectory;
const expanded = expandEnvVars(requested.trim());
if (!expanded) {
throw new Error("Directory path is empty.");
}
// ⚠️ Master switch: cd anywhere, no workspace boundary, no hot-swap needed.
// Any absolute path (any drive) or relative path just becomes the new CWD.
// Workspace root follows so subsequent relative paths resolve from there.
if (isUnrestrictedMode()) {
if (/[\0\r\n]/.test(expanded)) throw new Error("Path contains illegal control characters.");
const looksAbs = isAbsolute(expanded) || /^[A-Za-z]:[\\/]/.test(expanded);
const target = normalize(
looksAbs ? resolve(expanded) : resolve(state.currentWorkingDirectory, expanded),
);
let realTarget = target;
try {
realTarget = await realpath(target).catch(() => target);
} catch {
realTarget = target;
}
const stats = await stat(realTarget).catch(() => null);
if (!stats || !stats.isDirectory()) {
throw new Error(`Cannot change directory: '${realTarget}' does not exist or is not a directory.`);
}
state.currentWorkingDirectory = realTarget;
state.workspaceRoot = realTarget;
await savePersistedState(state);
return {
previous_directory: previous,
current_directory: state.currentWorkingDirectory,
workspace_root: state.workspaceRoot,
swapped_workspace: true,
message: `Changed directory to ${state.currentWorkingDirectory} (UNRESTRICTED MODE — workspace root follows).`,
};
}
const looksAbsolute =
isAbsolute(expanded) || /^[A-Za-z]:[\\/]/.test(expanded);
let target = looksAbsolute
? normalize(resolve(expanded))
: normalize(resolve(state.currentWorkingDirectory, expanded));
// Does this target already live inside the current root?
let insideRoot = false;
try {
assertInsideWorkspaceSync(state.workspaceRoot, target);
insideRoot = true;
} catch {
insideRoot = false;
}
let swapped = false;
if (!insideRoot) {
// Hot-swap only for absolute paths that exist as directories
if (!looksAbsolute) {
throw new Error(
`Access Denied: '${requested}' is outside the workspace. ` +
`Use an absolute path to switch workspaces (e.g. change_directory("D:\\\\projects\\\\app")).`,
);
}
const stats = await stat(target).catch(() => null);
if (!stats || !stats.isDirectory()) {
throw new Error(
`Cannot switch workspace: '${target}' does not exist or is not a directory.`,
);
}
// New workspace root
state.workspaceRoot = target;
state.currentWorkingDirectory = target;
swapped = true;
} else {
const safe = await assertInsideWorkspace(state.workspaceRoot, target);
const stats = await stat(safe);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${safe}`);
}
state.currentWorkingDirectory = safe;
}
await savePersistedState(state);
return {
previous_directory: previous,
current_directory: state.currentWorkingDirectory,
workspace_root: state.workspaceRoot,
swapped_workspace: swapped,
message: `Changed directory to ${state.currentWorkingDirectory}${swapped ? ' (workspace root changed)' : ''}.`,
};
}
/**
* Format the current working directory status for logging.
*/
export function formatCwdStatus(state: PluginState): string {
return `[Timber's Toolbox] CWD: ${state.currentWorkingDirectory} | Workspace: ${state.workspaceRoot}`;
}