// The model gets ordinary, anonymous HTTP reads—not a shell or host access.
import { validatePublicPath } from "@wimble/protocol";
export const API_ORIGIN = "https://wimble.run";
export const FILE_ORIGIN = "https://files.wimble.run";
export const MAX_FILE_BYTES = 256 * 1024;
export const MAX_TOOL_BYTES = 512 * 1024;
const encoder = new TextEncoder();
const definitions = /** @type {[string, string][]} */ ([
["list_directory", "List a directory's immediate entries. Follow each returned path (not parent + name); /users discovers profiles."],
["stat_path", "Inspect a file or directory's type, size, and metadata."],
["find_files", "Recursively list files inside one concrete subtree. Not supported at / or a mounted path; excludes mount overlays and returns at most 500 entries. Prefer list_directory unless recursion helps."],
["read_text", "Read a UTF-8 text file, up to 256 KiB. Directories and binary/media files cannot be read."],
]);
export const tools = definitions.map(([name, description]) => ({
type: /** @type {const} */ ("function"), name, description, strict: true,
parameters: { type: "object", properties: { path: { type: "string", description: "Absolute Wimble namespace path, e.g. /, /readme.md, /gabe." } }, required: ["path"], additionalProperties: false },
}));
/** One user turn owns its own byte budget, metadata cache and source list.
* @param {AbortSignal} signal
*/
export function createToolRunner(signal) {
let used = 0;
const stats = new Map();
const sources = new Set(/** @type {string[]} */ ([]));
/** @param {string} path */
async function metadata(path) {
if (!stats.has(path)) stats.set(path, await api("stat", path));
return stats.get(path);
}
/** @param {string} operation @param {string} path */
async function api(operation, path) {
const response = await fetch(`${API_ORIGIN}/api/fs/${operation}?path=${encodeURIComponent(path)}`, {
signal, credentials: "omit", redirect: "manual", headers: { accept: "application/json", "sec-fetch-site": "none", "sec-fetch-mode": "cors", "sec-fetch-dest": "empty" },
});
const text = await readBounded(response, MAX_TOOL_BYTES - used);
if (!response.ok) throw new Error(`Wimble ${operation} returned HTTP ${response.status}. Try another path or list its parent.`);
return JSON.parse(text);
}
return {
sources,
get exhausted() { return used >= MAX_TOOL_BYTES; },
/** @param {string} name @param {Record<string, unknown>} args */
async call(name, args) {
try {
signal.throwIfAborted();
if (used >= MAX_TOOL_BYTES) throw new Error("This turn's read budget is exhausted. Answer from what you already know.");
if (!args || Object.keys(args).length !== 1) throw new Error("Expected only a path argument.");
const path = publicPath(args.path);
let result;
if (name === "list_directory") result = await api("list", path);
else if (name === "stat_path") result = await metadata(path);
else if (name === "find_files") result = await api("find", path);
else if (name === "read_text") {
const stat = await metadata(path);
if (stat.type !== "file") throw new Error("Not a file. Use list_directory.");
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 256 KiB text limit.");
if (!isText(stat.contentType)) throw new Error("Not a supported text file.");
const response = await fetch(`${FILE_ORIGIN}${encodePath(path)}`, {
signal, credentials: "omit", redirect: "manual", headers: { "sec-fetch-site": "none", "sec-fetch-mode": "cors", "sec-fetch-dest": "empty" },
});
if (!response.ok || !isText(response.headers.get("content-type"))) {
await response.body?.cancel();
throw new Error(`Cannot read this text file (HTTP ${response.status}).`);
}
const text = await readBounded(response, Math.min(MAX_FILE_BYTES, MAX_TOOL_BYTES - used));
if (/[\u0000-\u0008\u000b\u000e-\u001f]/u.test(text)) throw new Error("File appears to contain binary data.");
result = { path, text };
} else throw new Error("Unknown tool.");
const output = JSON.stringify(result);
const bytes = encoder.encode(output).byteLength;
if (used + bytes > MAX_TOOL_BYTES) {
used = MAX_TOOL_BYTES;
throw new Error("Result exceeds this turn's remaining read budget; it was not included.");
}
used += bytes;
if (name === "read_text") sources.add(path);
return output;
} catch (error) {
signal.throwIfAborted();
// Never include fetch/SDK objects or host configuration in tool output.
return JSON.stringify({ error: error instanceof Error ? error.message.slice(0, 240) : "Read failed." });
}
},
};
}
/** @param {unknown} value */
export function publicPath(value) {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || encoder.encode(value).length > 1024 ||
value.split("/").some(part => part === "." || part === "..")) {
throw new Error("Use an absolute Wimble path, not a URL or relative path.");
}
return validatePublicPath(value);
}
/** @param {string} path */
export function encodePath(path) { return path.split("/").map(encodeURIComponent).join("/"); }
/** @param {string | null | undefined} type */
function isText(type) {
const mime = ((type ?? "").split(";", 1)[0] ?? "").trim().toLowerCase();
return mime.startsWith("text/") || ["application/json", "application/javascript", "application/xml", "application/yaml", "application/toml"].includes(mime);
}
/** Bound the actual stream, not just Content-Length or stat (either can lie).
* @param {Response} response
* @param {number} limit
*/
export async function readBounded(response, limit) {
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel();
throw new Error("Response exceeds the remaining read limit.");
}
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let bytes = 0;
let text = "";
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > limit) throw new Error("Response exceeds the remaining read limit.");
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
}