src / workspace.ts
src / workspace.ts
import { isAbsolute, relative, resolve } from "path";
import type { RunOptions } from "./shell";
/**
* Shared context handed to every tool module. Tool modules must never reach
* around this object to touch the filesystem or spawn processes -- containment,
* size limits and the capability toggles all live here.
*/
export interface Workspace {
/** Absolute, resolved workspace root. Nothing outside it may be touched. */
readonly root: string;
/** Byte ceiling for file reads, search scans and command output buffers. */
readonly maxBytes: number;
readonly maxFileSizeKb: number;
readonly commandTimeoutSec: number;
/** Ready-made options for `runCommand` from ./shell. */
readonly runOptions: RunOptions;
readonly allowWrite: boolean;
readonly allowShell: boolean;
readonly allowNetwork: boolean;
readonly allowGitWrite: boolean;
/**
* Resolves a model-supplied path against the root, throwing if it escapes.
* Every path a tool receives MUST go through this.
*/
resolveInRoot(candidate: string): string;
/** Root-relative display path, for messages back to the model. */
rel(absolutePath: string): string;
}
export interface WorkspaceInit {
root: string;
maxFileSizeKb: number;
commandTimeoutSec: number;
allowWrite: boolean;
allowShell: boolean;
allowNetwork: boolean;
allowGitWrite: boolean;
}
export function createWorkspace(init: WorkspaceInit): Workspace {
const root = resolve(init.root);
const maxBytes = init.maxFileSizeKb * 1024;
return {
root,
maxBytes,
maxFileSizeKb: init.maxFileSizeKb,
commandTimeoutSec: init.commandTimeoutSec,
runOptions: {
cwd: root,
timeoutMs: init.commandTimeoutSec * 1000,
maxBuffer: maxBytes,
},
allowWrite: init.allowWrite,
allowShell: init.allowShell,
allowNetwork: init.allowNetwork,
allowGitWrite: init.allowGitWrite,
resolveInRoot(candidate: string): string {
return resolveInRoot(root, candidate);
},
rel(absolutePath: string): string {
const value = relative(root, absolutePath);
return value === "" ? "." : value;
},
};
}
/**
* Single choke point for path containment. Resolves `candidate` against `root`
* and refuses anything that lands outside it.
*/
export function resolveInRoot(root: string, candidate: string): string {
const target = isAbsolute(candidate) ? resolve(candidate) : resolve(root, candidate);
const rel = relative(root, target);
if (rel === "") return target;
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Path "${candidate}" is outside the workspace root. Only paths under ${root} are allowed.`,
);
}
return target;
}
/** Directories never worth walking into. */
export const IGNORED_DIRS = new Set([
"node_modules",
".git",
".svn",
".hg",
"dist",
"build",
"out",
"target",
"__pycache__",
".venv",
"venv",
".next",
".nuxt",
".cache",
".lmstudio-backups",
]);
/**
* Models do not reliably use our parameter names -- they reach for the ones they
* have seen elsewhere (`file_path`, `old_string`). Rejecting those costs the
* model a whole turn for no reason, so every hot tool accepts the common aliases
* and funnels them through here: first non-empty wins.
*/
export function firstText(...values: Array<string | undefined | null>): string {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") return value;
}
return "";
}
/**
* A reasoning model's message text arrives as its chain-of-thought, an internal
* separator, then the actual answer. Callers want the answer, so everything up
* to the last separator is dropped.
*/
export function stripReasoning(text: string): string {
const separator = /__LM_STUDIO_INTERNAL_LSEP[A-Za-z0-9_]*__/g;
const matches = [...text.matchAll(separator)];
if (matches.length === 0) return text.trim();
const last = matches[matches.length - 1];
const answer = text.slice((last.index ?? 0) + last[0].length).trim();
// If the model put everything before the separator, keep that rather than
// returning nothing.
return answer === "" ? text.slice(0, last.index ?? 0).trim() : answer;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Truncates tool output so a small model's context is never blown out. */
export function clamp(text: string, maxChars: number, what = "output"): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n\n[truncated: ${what} was ${text.length} chars, limit ${maxChars}]`;
}
import { isAbsolute, relative, resolve } from "path";
import type { RunOptions } from "./shell";
/**
* Shared context handed to every tool module. Tool modules must never reach
* around this object to touch the filesystem or spawn processes -- containment,
* size limits and the capability toggles all live here.
*/
export interface Workspace {
/** Absolute, resolved workspace root. Nothing outside it may be touched. */
readonly root: string;
/** Byte ceiling for file reads, search scans and command output buffers. */
readonly maxBytes: number;
readonly maxFileSizeKb: number;
readonly commandTimeoutSec: number;
/** Ready-made options for `runCommand` from ./shell. */
readonly runOptions: RunOptions;
readonly allowWrite: boolean;
readonly allowShell: boolean;
readonly allowNetwork: boolean;
readonly allowGitWrite: boolean;
/**
* Resolves a model-supplied path against the root, throwing if it escapes.
* Every path a tool receives MUST go through this.
*/
resolveInRoot(candidate: string): string;
/** Root-relative display path, for messages back to the model. */
rel(absolutePath: string): string;
}
export interface WorkspaceInit {
root: string;
maxFileSizeKb: number;
commandTimeoutSec: number;
allowWrite: boolean;
allowShell: boolean;
allowNetwork: boolean;
allowGitWrite: boolean;
}
export function createWorkspace(init: WorkspaceInit): Workspace {
const root = resolve(init.root);
const maxBytes = init.maxFileSizeKb * 1024;
return {
root,
maxBytes,
maxFileSizeKb: init.maxFileSizeKb,
commandTimeoutSec: init.commandTimeoutSec,
runOptions: {
cwd: root,
timeoutMs: init.commandTimeoutSec * 1000,
maxBuffer: maxBytes,
},
allowWrite: init.allowWrite,
allowShell: init.allowShell,
allowNetwork: init.allowNetwork,
allowGitWrite: init.allowGitWrite,
resolveInRoot(candidate: string): string {
return resolveInRoot(root, candidate);
},
rel(absolutePath: string): string {
const value = relative(root, absolutePath);
return value === "" ? "." : value;
},
};
}
/**
* Single choke point for path containment. Resolves `candidate` against `root`
* and refuses anything that lands outside it.
*/
export function resolveInRoot(root: string, candidate: string): string {
const target = isAbsolute(candidate) ? resolve(candidate) : resolve(root, candidate);
const rel = relative(root, target);
if (rel === "") return target;
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(
`Path "${candidate}" is outside the workspace root. Only paths under ${root} are allowed.`,
);
}
return target;
}
/** Directories never worth walking into. */
export const IGNORED_DIRS = new Set([
"node_modules",
".git",
".svn",
".hg",
"dist",
"build",
"out",
"target",
"__pycache__",
".venv",
"venv",
".next",
".nuxt",
".cache",
".lmstudio-backups",
]);
/**
* Models do not reliably use our parameter names -- they reach for the ones they
* have seen elsewhere (`file_path`, `old_string`). Rejecting those costs the
* model a whole turn for no reason, so every hot tool accepts the common aliases
* and funnels them through here: first non-empty wins.
*/
export function firstText(...values: Array<string | undefined | null>): string {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") return value;
}
return "";
}
/**
* A reasoning model's message text arrives as its chain-of-thought, an internal
* separator, then the actual answer. Callers want the answer, so everything up
* to the last separator is dropped.
*/
export function stripReasoning(text: string): string {
const separator = /__LM_STUDIO_INTERNAL_LSEP[A-Za-z0-9_]*__/g;
const matches = [...text.matchAll(separator)];
if (matches.length === 0) return text.trim();
const last = matches[matches.length - 1];
const answer = text.slice((last.index ?? 0) + last[0].length).trim();
// If the model put everything before the separator, keep that rather than
// returning nothing.
return answer === "" ? text.slice(0, last.index ?? 0).trim() : answer;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Truncates tool output so a small model's context is never blown out. */
export function clamp(text: string, maxChars: number, what = "output"): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n\n[truncated: ${what} was ${text.length} chars, limit ${maxChars}]`;
}