Add bots files handling logic

This commit is contained in:
2026-10-21 17:56:09 +01:00
parent b0af4db1bf
commit d17a4a7bdd
97 changed files with 5843 additions and 41 deletions
@@ -0,0 +1,50 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import copyBotFile from "@/src/functions/backend/db/bot-files/copy-bot-file";
import type { HarnessTool } from "../../types";
import { fileRef, findNamedScope, type StorageToolContext } from "./storage-tool-context";
type Args = {
file_id: number;
scope: string;
path?: string;
};
/**
* Copies a file into another of the bot's directories: the coordinator
* bringing a worker's file into its own or into a workspace, or the other
* way round. Only available when the run reaches more than one.
*/
export default function copyFileTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "copy_file",
description: `Copy one of your files into another directory, by its ref id: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}. The copy is separate: deleting one leaves the other.`,
schema: z.object({
file_id: z.number().int().positive(),
scope: z.string().describe(`Where the copy goes, e.g. "bot:2" or "workspace:1"`),
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
}),
run: async ({ args }) => {
const target = findNamedScope({ scopes: context.scopes, name: args.scope });
if (!target) {
return `You can't write there. Your directories: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}.`;
}
const file = await copyBotFile({
scopes: context.scopes,
file_id: args.file_id,
bot_id: context.bot_id,
scope: target,
path: args.path,
user_id: context.user_id,
});
return `Copied to ${target.scope} ${target.owner_id} as ${file.path}. ${fileRef({ file_id: file.id! })}`;
},
};
}
@@ -0,0 +1,27 @@
import { z } from "zod";
import deleteBotFile from "@/src/functions/backend/db/bot-files/delete-bot-file";
import type { HarnessTool } from "../../types";
import type { StorageToolContext } from "./storage-tool-context";
type Args = {
file_id: number;
};
/**
* Deletes a file the bot saved, with its searchable text. Its bytes go for
* good, so a file the user may still want is moved, not deleted.
*/
export default function deleteFileTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "delete_file",
description: `Delete one of your files for good, by its ref id. Use it to clear space, never for something the user still needs.`,
schema: z.object({
file_id: z.number().int().positive(),
}),
run: async ({ args }) => {
const file = await deleteBotFile({ scopes: context.scopes, file_id: args.file_id });
return `${file.path} is deleted.`;
},
};
}
@@ -0,0 +1,121 @@
import { z } from "zod";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
import generateImage from "@/src/harness/image/generate-image";
import type { HarnessTool } from "../../types";
import {
describeScope,
fileRef,
findNamedScope,
formatBytes,
type StorageToolContext,
} from "./storage-tool-context";
type Args = {
prompt: string;
path?: string;
size?: string;
attach?: boolean;
scope?: string;
};
/**
* Makes an image with the image generation harness and saves it in the
* bot's files, so it can be sent, shown again later or used by another
* run. The bytes stay in the app's storage, never on a computer.
*/
export default function generateImageTool(context: StorageToolContext): HarnessTool<Args> {
const own = context.scopes[0];
return {
name: "generate_image",
description: `Make an image from a description and save it in your files. Describe what should be in it, in one clear prompt. The image is saved (so you can send it now and show it later) and can ride on this reply with "attach".`,
schema: z.object({
prompt: z.string().min(3).max(4000),
path: z
.string()
.max(300)
.optional()
.describe(`Where to save it, e.g. "images/cover.png"; one is named for you otherwise`),
size: z
.string()
.max(20)
.optional()
.describe(`e.g. "1024x1024", when the model takes one`),
attach: z.boolean().optional().describe(`Send it to the user with this reply`),
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
}),
run: async ({ args, signal }) => {
const named = args.scope
? findNamedScope({ scopes: context.scopes, name: args.scope })
: own;
if (!named) {
return `You can't save there. Your directories: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}.`;
}
const images = await generateImage({
prompt: args.prompt,
size: args.size,
signal,
bot_id: context.bot_id,
workspace_id: context.workspace_id,
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Image generation isn't working: ${message}`);
});
if (!images.length) {
return `The image model sent nothing back. Tell the user, and try a simpler prompt.`;
}
const saved: string[] = [];
for (let index = 0; index < images.length; index++) {
const image = images[index]!;
const ext = image.media_type.includes("png")
? "png"
: image.media_type.includes("webp")
? "webp"
: "jpg";
const name =
args.path && images.length == 1
? args.path
: `${stripExtension({ path: args.path || "image" })}${images.length > 1 ? `-${index + 1}` : ""}.${ext}`;
const file = await recordBotFile({
bot_id: context.bot_id,
scope: named.scope,
owner_id: named.owner_id,
path: name,
data: image.data,
purpose: "image",
source: "tool",
media_type: "image",
mime_type: image.media_type,
user_id: context.user_id,
});
await enqueueFileIngest({ file_id: file.id! });
if (args.attach && file.media_id) {
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
}
saved.push(
`${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) ${fileRef({ file_id: file.id! })}`,
);
}
return `Made and saved ${saved.length} image(s) in ${describeScope(named)}:\n${saved.join("\n")}${
args.attach ? "" : "\n\nIt isn't attached to this reply; the user sees it in your files."
}`;
},
};
}
function stripExtension({ path }: { path: string }) {
return path.replace(/\.[a-zA-Z0-9]{1,5}$/, "");
}
@@ -0,0 +1,50 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { HarnessTool } from "../../types";
import generateImageTool from "./generate-image-tool";
import grabStorageTools from "./grab-storage-tools";
import makePdfTool from "./make-pdf-tool";
import storageToolContext from "./storage-tool-context";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
user_id: number | string;
workspace_id?: number | string | null;
session_id?: number | string;
/**
* Called for a file the bot wrote with "attach" set, so the reply
* carries it.
*/
onAttach?: (params: { media_id: number; name: string }) => void;
};
/**
* The tools a bot uses to keep and search files (`src/harness/tools/
* storage/`): listing, reading, writing, moving, deleting and searching
* its own directory and, in a workspace or as the coordinator, the shared
* ones, plus making images and PDFs into them. Every run gets them, so a
* chat reply, a task and a scheduled run all work with the same files.
*/
export default async function grabBotFileTools({
bot,
user_id,
workspace_id,
session_id,
onAttach,
}: Params): Promise<HarnessTool[]> {
const all_workspace_ids =
bot.kind == "coordinator"
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived).map((w) => Number(w.id))
: [];
const context = storageToolContext({
bot,
user_id,
workspace_id,
session_id,
all_workspace_ids,
onAttach,
});
return [...grabStorageTools(context), generateImageTool(context), makePdfTool(context)];
}
@@ -0,0 +1,28 @@
import type { HarnessTool } from "../../types";
import copyFileTool from "./copy-file-tool";
import deleteFileTool from "./delete-file-tool";
import ingestFileTool from "./ingest-file-tool";
import listFilesTool from "./list-files-tool";
import moveFileTool from "./move-file-tool";
import readFileTool from "./read-file-tool";
import searchFilesTool from "./search-files-tool";
import type { StorageToolContext } from "./storage-tool-context";
import writeFileTool from "./write-file-tool";
/**
* The file tools a bot works with on every run, bound to its own
* directories (and, for the coordinator, every bot's and workspace's).
* `copy_file` only shows when the run reaches more than one directory.
*/
export default function grabStorageTools(context: StorageToolContext): HarnessTool[] {
return [
listFilesTool(context),
readFileTool(context),
writeFileTool(context),
searchFilesTool(context),
moveFileTool(context),
deleteFileTool(context),
ingestFileTool(context),
...(context.scopes.length > 1 ? [copyFileTool(context)] : []),
];
}
@@ -0,0 +1,44 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import ingestBotFile from "@/src/functions/backend/db/bot-files/ingest-bot-file";
import type { HarnessTool } from "../../types";
import type { StorageToolContext } from "./storage-tool-context";
type Args = {
file_id: number;
read_media?: boolean;
};
/**
* Ingests a file again: reads its text, chunks it and embeds it, so a
* changed or previously failed file is searchable. Images and audio are
* only read when asked for, since that costs a model call.
*/
export default function ingestFileTool(_context: StorageToolContext): HarnessTool<Args> {
return {
name: "ingest_file",
description: `Make one of your files searchable again (a course correction when search misses something you know is in it), by its ref id. Images and scans are only read when "read_media" is true.`,
schema: z.object({
file_id: z.number().int().positive(),
read_media: z.boolean().optional(),
}),
run: async ({ args, signal }) => {
const result = await ingestBotFile({
file_id: args.file_id,
read_media: args.read_media,
signal,
});
switch (result.status) {
case "ingested":
return `Ingested: ${result.chunks} passage(s), searchable now.`;
case "unavailable":
return `Its text is stored and searchable by words, but no embedding model is set, so meaning search is off.`;
case "skipped":
return `It's too large to read (over ${Math.round(AppData.IngestMaxBytes / (1024 * 1024))} MB).`;
default:
return `It has no text to read.`;
}
},
};
}
@@ -0,0 +1,62 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import listBotFiles from "@/src/functions/backend/db/bot-files/list-bot-files";
import type { HarnessTool } from "../../types";
import { fileRef, formatBytes, type StorageToolContext } from "./storage-tool-context";
type Args = {
folder?: string;
search?: string;
recursive?: boolean;
scope?: string;
};
/**
* Lists a bot's files: one folder's, or everything under it, or whatever
* matches a name. Each file comes with its ref so the bot can read it.
*/
export default function listFilesTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "list_files",
description: `List the files you have saved, newest first: one folder's with "folder", everything with "recursive", or the ones whose name or path has "search". Each line has its path, size and ref (e.g. [file:12]) to read it.`,
schema: z.object({
folder: z.string().max(AppData.BotFilePathMaxChars).optional(),
search: z.string().max(120).optional(),
recursive: z.boolean().optional(),
scope: z
.string()
.optional()
.describe(`"bot:<id>" or "workspace:<id>", to look in one of your directories only`),
}),
run: async ({ args }) => {
const folder = args.folder?.trim() || "";
const files = await listBotFiles({
scopes: context.scopes,
folder,
recursive: args.recursive,
search: args.search,
});
if (!files.length) {
return folder
? `Nothing in ${folder}.`
: `You have no files yet. Write one with write_file.`;
}
const lines = files.map((file) => {
const where = file.scope == "workspace" ? ` (workspace ${file.owner_id})` : "";
const ingested = file.ingested_at ? "" : " - not searchable yet";
return `- ${file.path} - ${formatBytes({ bytes: Number(file.size) || 0 })}${where}${ingested} ${fileRef({ file_id: file.id! })}`;
});
const more =
files.length >= AppData.BotFileListMaxItems
? `\n(Showing the first ${AppData.BotFileListMaxItems}. Narrow it with "folder" or "search".)`
: "";
return `${files.length} file(s):\n${lines.join("\n")}${more}`;
},
};
}
+185
View File
@@ -0,0 +1,185 @@
import { z } from "zod";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
import type { HarnessTool } from "../../types";
import {
describeScope,
fileRef,
findNamedScope,
formatBytes,
type StorageToolContext,
} from "./storage-tool-context";
type Args = {
path: string;
title?: string;
content: string;
attach?: boolean;
scope?: string;
};
const PageWidth = 595.28;
const PageHeight = 841.89;
const Margin = 56;
const BodySize = 11;
const Leading = 15;
/**
* Turns text into a PDF (headings, paragraphs and bullets) and saves it in
* the bot's files. Made in the app with `pdf-lib`, so it needs no
* computer.
*/
export default function makePdfTool(context: StorageToolContext): HarnessTool<Args> {
const own = context.scopes[0];
return {
name: "make_pdf",
description: `Make a PDF from text (markdown: "# " headings, "- " bullets, blank lines between paragraphs) and save it in your files. Send it to the user with "attach".`,
schema: z.object({
path: z.string().min(1).max(300).describe(`e.g. "reports/summary.pdf"`),
title: z.string().max(200).optional(),
content: z.string().min(1).max(100_000),
attach: z.boolean().optional(),
scope: z.string().optional(),
}),
run: async ({ args }) => {
const named = args.scope
? findNamedScope({ scopes: context.scopes, name: args.scope })
: own;
if (!named) {
return `You can't save there. Your directories: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}.`;
}
const bytes = await renderPdf({ title: args.title, content: args.content });
const path = args.path.toLowerCase().endsWith(".pdf") ? args.path : `${args.path}.pdf`;
const file = await recordBotFile({
bot_id: context.bot_id,
scope: named.scope,
owner_id: named.owner_id,
path,
data: bytes,
purpose: "pdf",
source: "tool",
mime_type: "application/pdf",
user_id: context.user_id,
});
await enqueueFileIngest({ file_id: file.id! });
if (args.attach && file.media_id) {
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
}
return `Made ${file.path} (${formatBytes({ bytes: bytes.byteLength })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`;
},
};
}
/**
* Lays the text out on A4 pages. Fonts are the PDF standard ones, so
* nothing has to be embedded.
*/
async function renderPdf({ title, content }: { title?: string; content: string }) {
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
let page = pdf.addPage([PageWidth, PageHeight]);
let y = PageHeight - Margin;
const newPage = () => {
page = pdf.addPage([PageWidth, PageHeight]);
y = PageHeight - Margin;
};
const write = ({
text,
size,
use_bold,
indent = 0,
spacing = 0,
}: {
text: string;
size: number;
use_bold?: boolean;
indent?: number;
spacing?: number;
}) => {
const used = use_bold ? bold : font;
const width = PageWidth - Margin * 2 - indent;
const words = text.split(/\s+/);
let line = "";
const emit = (value: string) => {
if (y - size < Margin) newPage();
page.drawText(value, {
x: Margin + indent,
y: y - size,
size,
font: used,
color: rgb(0.09, 0.09, 0.11),
});
y -= size + 4;
};
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (used.widthOfTextAtSize(candidate, size) > width && line) {
emit(line);
line = word;
} else {
line = candidate;
}
}
if (line) emit(line);
y -= spacing;
};
if (title) {
write({ text: title, size: 20, use_bold: true, spacing: 10 });
}
for (const block of content.replace(/\r\n/g, "\n").split(/\n{2,}/)) {
const trimmed = block.trim();
if (!trimmed) continue;
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
const level = heading[1]!.length;
write({
text: heading[2]!,
size: level == 1 ? 16 : level == 2 ? 14 : 12,
use_bold: true,
spacing: 8,
});
continue;
}
for (const line of trimmed.split("\n")) {
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
if (bullet) {
write({ text: `- ${bullet[1]}`, size: BodySize, indent: 12 });
} else {
write({ text: line.trim(), size: BodySize });
}
}
y -= Leading / 2;
}
return new Uint8Array(await pdf.save());
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import moveBotFile from "@/src/functions/backend/db/bot-files/move-bot-file";
import type { HarnessTool } from "../../types";
import { fileRef, type StorageToolContext } from "./storage-tool-context";
type Args = {
file_id: number;
path: string;
};
/**
* Renames or moves a file into another folder, inside its own directory.
*/
export default function moveFileTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "move_file",
description: `Rename a file or move it into another folder, by its ref id. The new path stays inside the same directory (yours, or the workspace's).`,
schema: z.object({
file_id: z.number().int().positive(),
path: z
.string()
.min(1)
.max(AppData.BotFilePathMaxChars)
.describe(`The new path, e.g. "archive/q3.md"`),
}),
run: async ({ args }) => {
const file = await moveBotFile({
scopes: context.scopes,
file_id: args.file_id,
path: args.path,
});
return `Moved to ${file.path}. ${fileRef({ file_id: file.id! })}`;
},
};
}
@@ -0,0 +1,95 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
import grabDirNames from "@/src/utils/grab-dir-names";
import readMedia from "@/src/harness/media/read-media";
import type { HarnessTool } from "../../types";
import { fileRef, type StorageToolContext } from "./storage-tool-context";
type Args = {
file_id?: number;
path?: string;
offset?: number;
scope?: string;
};
const TextExtensions = [
"txt",
"md",
"markdown",
"csv",
"json",
"html",
"htm",
"xml",
"yml",
"yaml",
"log",
"tsv",
];
/**
* Reads one of the bot's files: text as it is (a window at a time when it
* is long), an image or audio through the media harness, and a PDF or DOCX
* through the text extractor.
*/
export default function readFileTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "read_file",
description: `Read a file you saved, by its ref id or its path. Text comes back in a window of about ${Math.round(AppData.BotFileReadMaxChars / 1000)}k characters; use "offset" for the next part. Images and audio are read for you, and PDFs and Word documents are turned into text.`,
schema: z.object({
file_id: z.number().int().positive().optional(),
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
offset: z.number().int().min(0).optional(),
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>", with "path"`),
}),
run: async ({ args }) => {
const [scope_name, owner] = (args.scope || "").split(":");
const file = await grabBotFile({
scopes: context.scopes,
file_id: args.file_id,
scope: scope_name,
owner_id: owner,
path: args.path,
});
if (!file?.id || !file.media?.media_write_path) {
return `No file matches that. Use list_files to see what you have.`;
}
const { DATA_DIR } = grabDirNames();
const absolute = `${DATA_DIR}/${file.media.media_write_path}`;
const ext = (file.name || "").split(".").pop()?.toLowerCase() || "";
if (TextExtensions.includes(ext)) {
const text = await Bun.file(absolute).text();
const offset = args.offset || 0;
const window = text.slice(offset, offset + AppData.BotFileReadMaxChars);
const end = offset + window.length;
return [
`${file.path} ${fileRef({ file_id: file.id })}`,
window,
end < text.length
? `\n[Read ${offset}-${end} of ${text.length} characters. Call read_file with offset ${end} for the rest.]`
: "",
]
.filter(Boolean)
.join("\n\n");
}
const bytes = new Uint8Array(await Bun.file(absolute).arrayBuffer());
const text = await readMedia({
data: bytes,
media_type: file.mime_type || "application/octet-stream",
name: file.name || undefined,
bot_id: context.bot_id,
workspace_id: context.workspace_id,
});
return `${file.path} ${fileRef({ file_id: file.id })}\n\n${text.slice(0, AppData.BotFileReadMaxChars)}`;
},
};
}
@@ -0,0 +1,49 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import searchBotFiles from "@/src/functions/backend/db/bot-files/search-bot-files";
import type { HarnessTool } from "../../types";
import { fileRef, type StorageToolContext } from "./storage-tool-context";
type Args = {
query: string;
file_id?: number;
limit?: number;
};
/**
* Searches the bot's saved files by meaning (through the embedding
* vectors), falling back to words, and returns the passages with their
* file's ref so the bot can read more.
*/
export default function searchFilesTool(context: StorageToolContext): HarnessTool<Args> {
return {
name: "search_files",
description: `Search the files you have saved (documents the user gave you and what you wrote) and get the passages that answer "query", with their file's ref. Search before saying you don't know something that may be in a file, and before writing a document from memory.`,
schema: z.object({
query: z.string().min(1).max(500),
file_id: z.number().int().positive().optional().describe(`Only this file's passages`),
limit: z.number().int().min(1).max(20).optional(),
}),
run: async ({ args, signal }) => {
const result = await searchBotFiles({
scopes: context.scopes,
query: args.query,
file_id: args.file_id,
limit: args.limit || AppData.RagTopChunks,
signal,
bot_id: context.bot_id,
workspace_id: context.workspace_id,
});
if (!result.hits.length) return result.note;
const passages = result.hits.map((hit, index) => {
const where = hit.scope == "workspace" ? ` (workspace ${hit.scope})` : "";
return `## ${index + 1}. ${hit.name}${where} ${fileRef({ file_id: hit.file_id })}\n${hit.text.trim()}`;
});
return `${result.note}\n\n${passages.join("\n\n")}`;
},
};
}
@@ -0,0 +1,110 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabStorageScopes, { type StorageScope } from "@/src/utils/storage-access";
export type StorageToolContext = {
/**
* The user the run is for.
*/
user_id: number | string;
bot_id: number | string;
/**
* The directories the run may touch, itself first.
*/
scopes: StorageScope[];
/**
* The session being answered, when a file can ride on its reply.
*/
session_id?: number | string;
/**
* The workspace the run is in, for embeddings.
*/
workspace_id?: number | string | null;
/**
* Called with a file's media id when a tool wrote it and the bot asked
* for it to be attached to the reply.
*/
onAttach?: (params: { media_id: number; name: string }) => void;
};
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
user_id: number | string;
workspace_id?: number | string | null;
session_id?: number | string;
/**
* Every workspace, for the coordinator (it reaches them all).
*/
all_workspace_ids?: number[];
onAttach?: StorageToolContext["onAttach"];
};
/**
* The directories one run may touch, from the bot it's for. The
* coordinator reaches every bot's and workspace's files; a worker its own,
* plus its workspace's shared one while it works there.
*/
export default function storageToolContext({
bot,
user_id,
workspace_id,
session_id,
all_workspace_ids,
onAttach,
}: Params): StorageToolContext {
return {
user_id,
bot_id: bot.id || 0,
scopes: grabStorageScopes({
bot_id: bot.id || 0,
bot_kind: bot.kind,
workspace_id,
all_workspace_ids,
}),
session_id,
workspace_id,
onAttach,
};
}
/**
* A ref the chat renders as a chip, used in tool results so a file the bot
* names links to it.
*/
export function fileRef({ file_id }: { file_id: number | string }) {
return `[file:${file_id}]`;
}
/**
* A human size: "12.4 KB".
*/
export function formatBytes({ bytes }: { bytes: number }) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
/**
* The scope label a tool result shows: "your files" or "the workspace's
* shared files".
*/
export function describeScope({ scope, owner_id }: StorageScope) {
return scope == "workspace" ? `the workspace's shared files (#${owner_id})` : "your files";
}
/**
* Finds a scope the bot named, e.g. "workspace:3" or "bot:2".
*/
export function findNamedScope({
scopes,
name,
}: {
scopes: StorageScope[];
name?: string | null;
}) {
if (!name?.trim()) return undefined;
const [scope, owner] = name.trim().split(":");
return scopes.find((s) => s.scope == scope && String(s.owner_id) == owner);
}
@@ -0,0 +1,89 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import type { BotFilePurpose } from "@/src/dict/bot-file-dict";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
import type { HarnessTool } from "../../types";
import {
describeScope,
fileRef,
findNamedScope,
formatBytes,
type StorageToolContext,
} from "./storage-tool-context";
type Args = {
path: string;
content: string;
scope?: string;
attach?: boolean;
purpose?: BotFilePurpose;
};
/**
* Writes a text file (markdown, CSV, JSON, code, plain text) into one of
* the bot's directories, making folders as needed. The file stays for
* later runs and becomes searchable.
*/
export default function writeFileTool(context: StorageToolContext): HarnessTool<Args> {
const own = context.scopes[0];
return {
name: "write_file",
description: `Save a text file for later: markdown, CSV, JSON, code or plain text. Give it a path like "reports/q3.md"; folders are made as needed. Files belong to ${
own?.scope == "workspace" ? "the workspace you're working in" : "you"
} and stay between runs: write a file before telling the user something is saved, and search_files to find one you wrote before. It is readable and searchable at once.${
context.scopes.length > 1
? ` "scope" picks a directory: ${context.scopes.map((s) => `${s.scope}:${s.owner_id}`).join(", ")}.`
: ""
}`,
schema: z.object({
path: z
.string()
.min(1)
.max(AppData.BotFilePathMaxChars)
.describe(`Where it goes, e.g. "notes.md" or "reports/q3.md"`),
content: z.string().max(AppData.BotFileWriteMaxChars),
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
attach: z
.boolean()
.optional()
.describe(`Also send it to the user with this reply, as an attachment`),
purpose: z
.enum(["writing", "artifact"])
.optional()
.describe(`"writing" for notes and text, "artifact" for a finished file the user asked for`),
}),
run: async ({ args }) => {
const named = args.scope ? findNamedScope({ scopes: context.scopes, name: args.scope }) : own;
if (!named) {
return `You can't write there. Your directories: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}.`;
}
const file = await recordBotFile({
bot_id: context.bot_id,
scope: named.scope,
owner_id: named.owner_id,
path: args.path,
data: args.content,
purpose: args.purpose || "writing",
source: "bot-write",
user_id: context.user_id,
});
await enqueueFileIngest({ file_id: file.id! });
if (args.attach && file.media_id) {
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
}
return [
`Saved ${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`,
"Tell the user the file's name and what's in it.",
].join("\n\n");
},
};
}