Add tasks: the crew's shared to-do list
Bots and workspaces each keep a list of work tracked until it's done, and the bots move it along themselves as they work. - tasks (one collection per bot or per workspace), task_notes (the progress log) and task_media (a deliverable or a reference file, its bytes a private media row served at /media/private/<id>) - statuses todo/doing/blocked/done/cancelled, with blocked_reason while only the user can unblock it - visibility as SQL in src/utils/task-access: a user's own bots and workspaces, a run's own list plus its workspaces, everything for the coordinator; every tool and endpoint resolves through it - list_tasks, create_task and update_task on every run, so a chat reply, a task and a routine all read the list, log progress and file what they produced - TasksNote on every run and TaskReminderNote in chat, so a greeting or a proposed next step brings pending work up in one measured line - tasks group on the workspace dashboard and a tasks modal in the conversation header, sharing one form, row and detail panel - a bot's or workspace's own chat uploads are read before the delete so none are stranded; bot files keep their folder Also corrects the README's RAM guidance and table alignment.
This commit is contained in:
@@ -74,7 +74,14 @@ export default async function grabBotChatTools({
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const bot_id = bot.id || 0;
|
||||
const tools = [
|
||||
...grabBotTools({ user_id, bot_id, workspace_id, computers, session_id }),
|
||||
...(await grabBotTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computers,
|
||||
session_id,
|
||||
is_coordinator: bot.kind == "coordinator",
|
||||
})),
|
||||
startTaskTool({
|
||||
user_id,
|
||||
bot_id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import grabBotTaskTools from "./tasks/grab-bot-task-tools";
|
||||
import createScheduleTool from "./create-schedule-tool";
|
||||
import deleteScheduleTool from "./delete-schedule-tool";
|
||||
import listSchedulesTool from "./list-schedules-tool";
|
||||
@@ -25,6 +26,11 @@ type Params = {
|
||||
* The session being answered, so the bot can react to its messages.
|
||||
*/
|
||||
session_id?: number | string;
|
||||
/**
|
||||
* Whether the run is the coordinator, which reaches every task and can
|
||||
* add to any bot's or workspace's list.
|
||||
*/
|
||||
is_coordinator?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,13 +38,14 @@ type Params = {
|
||||
* works: its memory and instructions, its schedules, and in a workspace
|
||||
* with several computers switching the default one.
|
||||
*/
|
||||
export default function grabBotTools({
|
||||
export default async function grabBotTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computers = [],
|
||||
session_id,
|
||||
}: Params): HarnessTool[] {
|
||||
is_coordinator,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
return [
|
||||
updateMemoryTool({ user_id, bot_id, workspace_id }),
|
||||
updateInstructionsTool({ user_id, bot_id, workspace_id }),
|
||||
@@ -50,5 +57,11 @@ export default function grabBotTools({
|
||||
...(workspace_id && computers.length > 1
|
||||
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
|
||||
: []),
|
||||
...(await grabBotTaskTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
is_coordinator: Boolean(is_coordinator),
|
||||
workspace_id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
|
||||
import createTask from "@/src/functions/backend/db/tasks/create-task";
|
||||
import findDuplicateTask from "@/src/functions/backend/db/tasks/find-duplicate-task";
|
||||
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in, if any: a bot adding a task there
|
||||
* defaults to the workspace's shared list.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The coordinator's targets: every worker and live workspace. A worker
|
||||
* gets neither and may only add to its own list or the workspace it
|
||||
* works in.
|
||||
*/
|
||||
workers?: BUN_SQLITE_COBBLE_BOTS[];
|
||||
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
title: string;
|
||||
description: string;
|
||||
bot_id?: number;
|
||||
workspace_id?: number;
|
||||
status?: TaskStatus;
|
||||
};
|
||||
|
||||
type Place = { bot_id?: number; workspace_id?: number };
|
||||
|
||||
/**
|
||||
* Worked out from the arguments and who is asking: where the task goes, or
|
||||
* why it can't. A worker may only add to its own list or the workspace it
|
||||
* works in; the coordinator to any worker's, any workspace's, or its own.
|
||||
*/
|
||||
function resolvePlace({
|
||||
args,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
is_coordinator,
|
||||
}: {
|
||||
args: Args;
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
is_coordinator: boolean;
|
||||
}): Place | string {
|
||||
const target_bot = args.bot_id ? Number(args.bot_id) : null;
|
||||
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
|
||||
|
||||
if (target_bot && target_workspace) {
|
||||
return `A task belongs to a bot or a workspace, not both. Pick one.`;
|
||||
}
|
||||
|
||||
if (is_coordinator) {
|
||||
if (target_workspace) return { workspace_id: target_workspace };
|
||||
if (target_bot) return { bot_id: target_bot };
|
||||
return { bot_id: Number(bot_id) };
|
||||
}
|
||||
|
||||
if (target_bot && target_bot != Number(bot_id)) {
|
||||
return `You can only add tasks to your own list (#${bot_id}).`;
|
||||
}
|
||||
|
||||
if (target_workspace && Number(workspace_id || 0) != target_workspace) {
|
||||
return `You can only add to this workspace's list (#${workspace_id || "none"}) or your own.`;
|
||||
}
|
||||
|
||||
if (target_workspace) return { workspace_id: target_workspace };
|
||||
if (target_bot) return { bot_id: target_bot };
|
||||
|
||||
/**
|
||||
* In a workspace, the workspace's list is the right home: work there is
|
||||
* shared work.
|
||||
*/
|
||||
return workspace_id ? { workspace_id: Number(workspace_id) } : { bot_id: Number(bot_id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a bot add a task to a list it works on: its own, or a workspace's.
|
||||
* The coordinator can add to any worker's list and any workspace's.
|
||||
*/
|
||||
export default function createTaskTool({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
is_coordinator,
|
||||
workers = [],
|
||||
workspaces = [],
|
||||
}: Params): HarnessTool<Args> {
|
||||
const crew = workers.map((worker) => `${worker.id}: ${worker.name}`).join("; ");
|
||||
const places = workspaces.map((workspace) => `${workspace.id}: ${workspace.name}`).join("; ");
|
||||
|
||||
return {
|
||||
name: "create_task",
|
||||
description: is_coordinator
|
||||
? `Add a task to a list: a worker's own (${crew || "no workers yet"}), a workspace's shared one (${places || "no workspaces yet"}), or your own (leave both ids out). Write the description as a brief: what to do, where, and how you'll know it's done. A workspace's tasks are visible to every bot working there.`
|
||||
: `Add a task to a list: ${workspace_id ? "this workspace's shared one (leave the ids out), or your own (bot_id: " + bot_id + ")" : "your own (leave the ids out)"}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
||||
schema: z.object({
|
||||
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
|
||||
description: z
|
||||
.string()
|
||||
.max(AppData.TaskDescriptionMaxLength)
|
||||
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
|
||||
bot_id: z.number().int().optional().describe(
|
||||
is_coordinator
|
||||
? `Whose own list, when it isn't a workspace's. Leave out for your own.`
|
||||
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's.`,
|
||||
),
|
||||
workspace_id: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
is_coordinator
|
||||
? `A workspace's shared list, when it isn't a bot's own.`
|
||||
: `This workspace's shared list (${workspace_id || "none"}).`,
|
||||
),
|
||||
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const place = resolvePlace({ args, bot_id, workspace_id, is_coordinator });
|
||||
|
||||
if (typeof place == "string") return place;
|
||||
|
||||
const existing = await grabTasks({
|
||||
bot_id,
|
||||
is_coordinator,
|
||||
only_bot_id: place.bot_id,
|
||||
only_workspace_id: place.workspace_id,
|
||||
open_only: true,
|
||||
});
|
||||
|
||||
const twin = findDuplicateTask({
|
||||
tasks: existing,
|
||||
title: args.title,
|
||||
bot_id: place.bot_id || null,
|
||||
workspace_id: place.workspace_id || null,
|
||||
});
|
||||
|
||||
if (twin) {
|
||||
return `Task #${twin.id} "${twin.title}" is already open with that title. Update it with update_task instead of adding a second one for the same work.`;
|
||||
}
|
||||
|
||||
const task = await createTask({
|
||||
user_id,
|
||||
bot_id: place.bot_id,
|
||||
workspace_id: place.workspace_id,
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
status: args.status as never,
|
||||
created_by_bot_id: bot_id,
|
||||
});
|
||||
|
||||
return `Task #${task.id} "${task.title}" added ${place.workspace_id ? "to this workspace's list" : "to your list"}. Check it with list_tasks before you mention it to the user.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabStorageScopes from "@/src/utils/storage-access";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import createTaskTool from "./create-task-tool";
|
||||
import listTasksTool from "./list-tasks-tool";
|
||||
import updateTaskTool from "./update-task-tool";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Whether the run is the coordinator, which reaches every task, can add
|
||||
* to any list and reaches every folder.
|
||||
*/
|
||||
is_coordinator: boolean;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
|
||||
* shared to-do list, one per bot and one per workspace, so a chat reply, a
|
||||
* task and a scheduled run all read it, move it along and file what they
|
||||
* produced. The coordinator's create_task may target any worker or
|
||||
* workspace; its update_task may link any file it can reach.
|
||||
*/
|
||||
export default async function grabBotTaskTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
is_coordinator,
|
||||
workspace_id,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
let workers: BUN_SQLITE_COBBLE_BOTS[] = [];
|
||||
let workspaces: BUN_SQLITE_COBBLE_WORKSPACES[] = [];
|
||||
let all_workspace_ids: number[] = [];
|
||||
|
||||
if (is_coordinator) {
|
||||
workers = await grabCrewWorkers({ workspace_id });
|
||||
workspaces = (await grabUserWorkspaces({ user_id })).filter((workspace) => !workspace.archived);
|
||||
all_workspace_ids = workspaces.map((workspace) => Number(workspace.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* `grabStorageScopes` wants a numeric bot id.
|
||||
*/
|
||||
const numeric_bot_id = Number(bot_id);
|
||||
|
||||
const scopes = grabStorageScopes({
|
||||
bot_id: numeric_bot_id,
|
||||
bot_kind: is_coordinator ? "coordinator" : "worker",
|
||||
workspace_id: workspace_id ? Number(workspace_id) : null,
|
||||
all_workspace_ids,
|
||||
});
|
||||
|
||||
return [
|
||||
listTasksTool({ user_id, bot_id, is_coordinator, workspace_id }),
|
||||
createTaskTool({ user_id, bot_id, workspace_id, is_coordinator, workers, workspaces }),
|
||||
updateTaskTool({ user_id, bot_id, is_coordinator, scopes }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
|
||||
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
user_id: number | string;
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The workspace the run is in, if any.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
scope?: "mine" | "workspace" | "all";
|
||||
status?: TaskStatus;
|
||||
open_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot read the tasks it can work on: its own list and every
|
||||
* workspace it works in, or for the coordinator the whole install. Used
|
||||
* both to plan work and to remind the user of what's pending.
|
||||
*/
|
||||
export default function listTasksTool({ bot_id, user_id, is_coordinator, workspace_id }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_tasks",
|
||||
description: `List tasks: yours, the ones in a workspace you work in, or ${is_coordinator ? "every task in the install" : "every task you can reach"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
|
||||
schema: z.object({
|
||||
scope: z
|
||||
.enum(["mine", "workspace", "all"])
|
||||
.optional()
|
||||
.describe(`"mine" for your own list, "workspace" for ${workspace_id ? "this workspace's" : "a workspace's (none open)"}, "all" for everything you reach. Defaults to "all".`),
|
||||
status: z.enum(TaskStatusValues).optional(),
|
||||
open_only: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(`Only the open ones (to do, doing, blocked). Defaults to false.`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const scope = args.scope || "all";
|
||||
const only_workspace_id =
|
||||
scope == "workspace" && workspace_id ? Number(workspace_id) : undefined;
|
||||
|
||||
const tasks = await grabTasks({
|
||||
bot_id,
|
||||
is_coordinator,
|
||||
only_bot_id: scope == "mine" ? Number(bot_id) : undefined,
|
||||
only_workspace_id,
|
||||
statuses: args.status ? [args.status] : undefined,
|
||||
open_only: args.open_only || false,
|
||||
limit: AppData.TaskListToolLimit,
|
||||
});
|
||||
|
||||
if (!tasks.length) {
|
||||
return args.open_only
|
||||
? `You have no open tasks${args.status ? ` with that status` : ""}.`
|
||||
: `No tasks${args.status ? ` with that status` : ""}.`;
|
||||
}
|
||||
|
||||
const names = await grabWorkspaceNames({
|
||||
workspace_ids: tasks.map((task) => Number(task.workspace_id || 0)).filter(Boolean),
|
||||
});
|
||||
|
||||
return tasks
|
||||
.map((task) => {
|
||||
const where = task.workspace_id
|
||||
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}`
|
||||
: `on ${task.bot_name || `bot ${task.bot_id}`}`;
|
||||
const bits = [
|
||||
`#${task.id}`,
|
||||
`[${task.status}]`,
|
||||
task.title,
|
||||
`(${where})`,
|
||||
Number(task.media_count || 0) ? `${task.media_count} file(s)` : "",
|
||||
task.status == "blocked" && task.blocked_reason ? `blocked: ${task.blocked_reason}` : "",
|
||||
task.latest_note ? `latest: ${oneLine({ text: task.latest_note })}` : "",
|
||||
];
|
||||
|
||||
return `- ${bits.filter(Boolean).join(" ")}`;
|
||||
})
|
||||
.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A note or reason on one line, so a list stays a list.
|
||||
*/
|
||||
function oneLine({ text }: { text: string }) {
|
||||
return String(text).replace(/\s+/g, " ").trim().slice(0, 160);
|
||||
}
|
||||
|
||||
async function grabWorkspaceNames({ workspace_ids }: { workspace_ids: number[] }) {
|
||||
const names = new Map<number, string>();
|
||||
|
||||
if (!workspace_ids.length) return names;
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT id, name FROM workspaces WHERE id IN (${workspace_ids.map(() => "?").join(", ")})`,
|
||||
values: workspace_ids,
|
||||
});
|
||||
|
||||
for (const workspace of res.payload || []) {
|
||||
if (workspace.id) names.set(Number(workspace.id), workspace.name || `workspace ${workspace.id}`);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
|
||||
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
|
||||
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
|
||||
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
|
||||
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
|
||||
import updateTask from "@/src/functions/backend/db/tasks/update-task";
|
||||
import type { StorageScope } from "@/src/utils/storage-access";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The run's folders, for linking a deliverable by its `[file:ID]` ref.
|
||||
*/
|
||||
scopes?: StorageScope[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
task_id: number;
|
||||
status?: TaskStatus;
|
||||
blocked_reason?: string;
|
||||
note?: string;
|
||||
deliverable_file_ids?: number[];
|
||||
reference_file_ids?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a bot move a task along: set its status, say what's blocking it, log
|
||||
* a progress note, and file the deliverables it produced. One call does all
|
||||
* of them, so a step in the work is a step in the log.
|
||||
*/
|
||||
export default function updateTaskTool({ user_id, bot_id, is_coordinator, scopes = [] }: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "update_task",
|
||||
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
|
||||
schema: z.object({
|
||||
task_id: z.number().int(),
|
||||
status: z.enum(TaskStatusValues).optional(),
|
||||
blocked_reason: z
|
||||
.string()
|
||||
.max(AppData.TaskNoteMaxLength)
|
||||
.optional()
|
||||
.describe(`What the user has to do to unblock it; set with status blocked`),
|
||||
note: z
|
||||
.string()
|
||||
.max(AppData.TaskNoteMaxLength)
|
||||
.optional()
|
||||
.describe(`A line of progress for the task's log`),
|
||||
deliverable_file_ids: z
|
||||
.array(z.number().int())
|
||||
.optional()
|
||||
.describe(`The [file:ID] refs of what the task produced`),
|
||||
reference_file_ids: z
|
||||
.array(z.number().int())
|
||||
.optional()
|
||||
.describe(`The [file:ID] refs of files it works from`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator });
|
||||
const said: string[] = [];
|
||||
|
||||
if (args.status || args.blocked_reason !== undefined) {
|
||||
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
|
||||
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
|
||||
}
|
||||
|
||||
await updateTask({
|
||||
user_id,
|
||||
task_id: task.id!,
|
||||
status: args.status as never,
|
||||
blocked_reason: args.blocked_reason ?? undefined,
|
||||
updated_by_bot_id: bot_id,
|
||||
});
|
||||
|
||||
if (args.status) said.push(`status is now ${args.status}`);
|
||||
}
|
||||
|
||||
if (args.note?.trim()) {
|
||||
await addTaskNote({ task_id: task.id!, text: args.note, author: "bot", bot_id });
|
||||
said.push(`note logged`);
|
||||
}
|
||||
|
||||
for (const [kind, ids] of [
|
||||
["deliverable", args.deliverable_file_ids],
|
||||
["reference", args.reference_file_ids],
|
||||
] as const) {
|
||||
for (const file_id of ids || []) {
|
||||
try {
|
||||
const { file, existed } = await attachTaskFile({
|
||||
task_id: task.id!,
|
||||
file_id,
|
||||
user_id,
|
||||
scopes,
|
||||
kind,
|
||||
added_by_bot_id: bot_id,
|
||||
});
|
||||
|
||||
said.push(`${existed ? "already linked" : "linked"} ${kind} "${file.name}"`);
|
||||
} catch (error: unknown) {
|
||||
said.push(
|
||||
`couldn't link file ${file_id}: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!said.length) {
|
||||
return `Nothing to change on #${task.id}.`;
|
||||
}
|
||||
|
||||
const files = await grabTaskMedia({ task_id: task.id! });
|
||||
|
||||
return `Task #${task.id} "${task.title}": ${said.join(", ")}. It now carries ${files.length} file(s). Read it back with list_tasks before you tell the user.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user