MiniCPM5-2B-WebGPU-Pi / src /workspace.js
Xenova's picture
Xenova HF Staff
Upload 65 files
ea7122c verified
Raw History Blame Contribute Delete
9.09 kB
import { Bash } from '../vendor/just-bash.js';
export const WORKSPACE_DB_NAME = 'minicpm5-gguf-workspace';
export const WORKSPACE_ROOT = '/workspace';
export const MAX_EDITOR_BYTES = 1024 * 1024;
export const EXECUTION_LIMITS = Object.freeze({
maxExecutionTimeMs: 10_000,
maxCommandCount: 3_000,
maxFileSystemBytes: 16 * 1024 * 1024,
maxOutputSize: 262_144,
maxSourceBytes: 131_072,
});
export const DEFAULT_FILES = Object.freeze({
'/workspace/README.md': '# Browser workspace\n\nYour files are saved in this browser.\n',
'/workspace/sales.csv': 'product,units\nNotebook,12\nPencil,30\nFolder,8\n',
'/workspace/products.json': JSON.stringify([
{ product: 'Notebook', price: 5 },
{ product: 'Pencil', price: 2 },
{ product: 'Folder', price: 3 },
{ product: 'Eraser', price: 1.5 },
{ product: 'Stapler', price: 4.99 },
{ product: 'Ruler', price: 2.5 },
], null, 2) + '\n',
});
const encoder = new TextEncoder();
const generatedRoots = ['/bin', '/usr/bin', '/dev', '/proc'];
const isGenerated = path => generatedRoots.some(root => path === root || path.startsWith(root + '/'));
export function truncateOutput(value, limit = 6_000) {
return value.length > limit
? value.slice(0, limit) + '\n[Output truncated at 6,000 characters; use head, grep, or a narrower query.]'
: value;
}
function result(content, details = {}) {
return { content: truncateOutput(content), details };
}
function checkAbort(signal) {
if (signal?.aborted) throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
}
function objectSchema(properties) {
return { type: 'object', properties, required: Object.keys(properties), additionalProperties: false };
}
function stringSchema(description) {
return { type: 'string', ...(description ? { description } : {}) };
}
export function bytesToBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 32_768) {
binary += String.fromCharCode(...bytes.subarray(i, i + 32_768));
}
return btoa(binary);
}
export function base64ToBytes(base64) {
return Uint8Array.from(atob(base64), char => char.charCodeAt(0));
}
/** A JavaScript-only shell: no host filesystem, network, Python, or JavaScript command. */
export function createWorkspace(files = DEFAULT_FILES, entries) {
const initialFiles = entries
? Object.fromEntries(entries.filter(([, entry]) => entry.type === 'file').map(([path, entry]) => [
path, { mode: entry.mode, content: base64ToBytes(entry.base64) },
]))
: files;
const bash = new Bash({
files: initialFiles,
cwd: WORKSPACE_ROOT,
executionLimitProfile: 'hardened',
executionLimits: { ...EXECUTION_LIMITS },
});
const resolve = path => bash.fs.resolvePath(WORKSPACE_ROOT, path);
const ready = (async () => {
// File construction creates parents; restore their original modes afterward.
for (const [path, entry] of entries ?? []) {
if (entry.type === 'directory') {
await bash.fs.mkdir(path, { recursive: true });
await bash.fs.chmod(path, entry.mode);
} else if (entry.type === 'symlink') {
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
await bash.fs.mkdir(parent, { recursive: true });
await bash.fs.symlink(entry.target, path);
}
}
})();
async function read(path, { signal } = {}) {
await ready;
checkAbort(signal);
return bash.fs.readFile(resolve(path));
}
async function write(path, content, { signal } = {}) {
await ready;
checkAbort(signal);
if (typeof content !== 'string') throw new TypeError('File content must be a UTF-8 string.');
if (encoder.encode(content).length > MAX_EDITOR_BYTES) throw new Error('The editor accepts files up to 1 MiB.');
const absolutePath = resolve(path);
await bash.fs.mkdir(absolutePath.slice(0, absolutePath.lastIndexOf('/')) || '/', { recursive: true });
checkAbort(signal);
await bash.fs.writeFile(absolutePath, content);
}
const tools = [
{
name: 'bash',
description: 'Run a bash command in /workspace. Supports cat, ls, grep, sed, awk, jq, sort, pipes, redirects, and shell scripts. No network, Node.js, Python, npm, or native binaries. Files persist across commands; cd and variables reset each call.',
parameters: objectSchema({ command: stringSchema('The shell command to execute.') }),
async execute({ command }, { signal } = {}) {
await ready;
checkAbort(signal);
const execution = await bash.exec(command, { signal });
checkAbort(signal);
const content = `${execution.stdout}${execution.stderr ? '\nSTDERR:\n' + execution.stderr : ''}\nExit code: ${execution.exitCode}`;
// The agent's repeated-failure guard relies on tools rejecting failed commands.
if (execution.exitCode !== 0) {
const error = new Error(truncateOutput(content));
error.exitCode = execution.exitCode;
throw error;
}
return result(content, { exitCode: execution.exitCode });
},
},
{
name: 'read',
description: 'Read a UTF-8 text file. Use bash head/grep for large files.',
parameters: objectSchema({ path: stringSchema() }),
async execute({ path }, { signal } = {}) {
await ready;
checkAbort(signal);
const absolutePath = await bash.fs.realpath(resolve(path));
return result(await read(absolutePath, { signal }), { path: absolutePath });
},
},
{
name: 'write',
description: 'Create or replace a UTF-8 text file. Parent directories are created.',
parameters: objectSchema({ path: stringSchema(), content: stringSchema() }),
async execute({ path, content }, context = {}) {
await write(path, content, context);
return result('Wrote ' + resolve(path));
},
},
{
name: 'edit',
description: 'Replace one exact occurrence of oldText with newText in a UTF-8 file. oldText must match exactly once.',
parameters: objectSchema({ path: stringSchema(), oldText: stringSchema(), newText: stringSchema() }),
async execute({ path, oldText, newText }, context = {}) {
const content = await read(path, context);
if (!oldText || content.split(oldText).length !== 2) {
throw new Error('oldText must match exactly once. Read the file first.');
}
await write(path, content.replace(oldText, () => newText), context);
return result('Edited ' + resolve(path));
},
},
];
async function snapshot() {
await ready;
const files = {};
for (const path of bash.fs.getAllPaths().sort()) {
if (!isGenerated(path) && (await bash.fs.lstat(path)).isFile) files[path] = await bash.fs.readFile(path);
}
return files;
}
async function serialize() {
await ready;
const entries = [];
for (const path of bash.fs.getAllPaths().sort()) {
if (isGenerated(path)) continue;
const stat = await bash.fs.lstat(path);
if (stat.isSymbolicLink) {
entries.push([path, { type: 'symlink', target: await bash.fs.readlink(path) }]);
} else if (stat.isDirectory) {
entries.push([path, { type: 'directory', mode: stat.mode }]);
} else if (stat.isFile) {
entries.push([path, { type: 'file', base64: bytesToBase64(await bash.fs.readFileBuffer(path)), mode: stat.mode }]);
}
}
return entries;
}
return { bash, tools, ready, read, write, snapshot, serialize };
}
/** Separate from legacy Pi storage because the new agent uses a different message schema. */
export async function createStore(indexedDB = globalThis.indexedDB) {
if (!indexedDB) throw new Error('Browser storage is unavailable.');
const db = await new Promise((resolve, reject) => {
const request = indexedDB.open(WORKSPACE_DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore('state');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('Close other tabs to enable browser storage.'));
});
return {
async load() {
return new Promise((resolve, reject) => {
const transaction = db.transaction('state');
const request = transaction.objectStore('state').get('current');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error ?? new Error('Loading browser storage was aborted.'));
});
},
async save(state) {
return new Promise((resolve, reject) => {
const transaction = db.transaction('state', 'readwrite');
transaction.objectStore('state').put(state, 'current');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error ?? new Error('Saving browser storage was aborted.'));
});
},
close() { db.close(); },
};
}