feat(HAI-042): add file attachment support for tasks

- Expand MIME type support for text file uploads
- Add --attach flag to CLI task create command
- Surface attachments to triage agent with image content support
- Reference attachments in executor prompt for task execution context
- Add drag-and-drop file upload on dashboard task cards
This commit is contained in:
Dustin Byrne
2026-03-26 00:06:41 -04:00
parent afcd074caa
commit 123c481dfc
13 changed files with 592 additions and 27 deletions

View File

@@ -8,21 +8,23 @@ hai — AI-orchestrated task board
Usage:
hai dashboard Start the board web UI
hai task create [desc] Create a new task (goes to triage)
hai task create [desc] [--attach f] Create a new task (goes to triage)
hai task list List all tasks
hai task show <id> Show task details, steps, log
hai task move <id> <col> Move a task to a column
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
hai task log <id> <message> Add a log entry
hai task merge <id> Merge an in-review task and close it
hai task attach <id> <file> Attach a screenshot image to a task
hai task attach <id> <file> Attach a file to a task
Options:
--port, -p <port> Dashboard port (default: 4040)
--engine Enable AI engine (auto-specify + execute tasks)
--attach <file> Attach file(s) on task create (repeatable)
--help, -h Show this help
Columns: triage, todo, in-progress, in-review, done
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
Requires configured API keys — run "pi" first to set up authentication.
@@ -55,8 +57,19 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const title = args.slice(2).join(" ");
await runTaskCreate(title || undefined);
const createArgs = args.slice(2);
const attachFiles: string[] = [];
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
attachFiles.push(createArgs[i + 1]);
i++; // skip the value
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined);
break;
}
case "list":

View File

@@ -22,7 +22,7 @@ vi.mock("@hai/core", () => {
vi.mock("@hai/engine", () => ({ aiMergeTask: vi.fn() }));
import { TaskStore } from "@hai/core";
import { runTaskShow } from "./task.js";
import { runTaskShow, runTaskCreate } from "./task.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -92,3 +92,117 @@ describe("runTaskShow", () => {
expect(headerLine![0]).not.toContain("This is the full description");
});
});
// Mock fs/promises for runTaskCreate attach tests
vi.mock("node:fs/promises", () => ({
readFile: vi.fn(),
}));
describe("runTaskCreate with --attach", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockAddAttachment: ReturnType<typeof vi.fn>;
let mockReadFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockAddAttachment = vi.fn().mockResolvedValue({
filename: "abc123-test.png",
originalName: "test.png",
mimeType: "image/png",
size: 2048,
createdAt: new Date().toISOString(),
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue({
id: "HAI-002",
description: "test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
addAttachment: mockAddAttachment,
}));
const fsMod = await import("node:fs/promises");
mockReadFile = vi.mocked(fsMod.readFile);
mockReadFile.mockResolvedValue(Buffer.from("file content"));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates task and attaches files when attachFiles provided", async () => {
await runTaskCreate("test task", ["/tmp/test.png"]);
expect(mockAddAttachment).toHaveBeenCalledOnce();
expect(mockAddAttachment).toHaveBeenCalledWith(
"HAI-002",
"test.png",
expect.any(Buffer),
"image/png",
);
const attachLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Attached"),
);
expect(attachLine).toBeDefined();
});
it("attaches multiple files", async () => {
mockAddAttachment.mockResolvedValueOnce({
filename: "abc-screenshot.png",
originalName: "screenshot.png",
mimeType: "image/png",
size: 1024,
createdAt: new Date().toISOString(),
}).mockResolvedValueOnce({
filename: "def-crash.log",
originalName: "crash.log",
mimeType: "text/plain",
size: 512,
createdAt: new Date().toISOString(),
});
await runTaskCreate("test task", ["/tmp/screenshot.png", "/tmp/crash.log"]);
expect(mockAddAttachment).toHaveBeenCalledTimes(2);
});
it("skips files with unsupported extensions", async () => {
await runTaskCreate("test task", ["/tmp/file.exe"]);
expect(mockAddAttachment).not.toHaveBeenCalled();
const errLine = errorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Unsupported"),
);
expect(errLine).toBeDefined();
});
it("skips unreadable files", async () => {
mockReadFile.mockRejectedValueOnce(new Error("ENOENT"));
await runTaskCreate("test task", ["/tmp/missing.png"]);
expect(mockAddAttachment).not.toHaveBeenCalled();
const errLine = errorSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Cannot read"),
);
expect(errLine).toBeDefined();
});
it("creates task without attachments when attachFiles is undefined", async () => {
await runTaskCreate("test task");
expect(mockAddAttachment).not.toHaveBeenCalled();
});
});

View File

@@ -10,7 +10,7 @@ async function getStore(): Promise<TaskStore> {
return store;
}
export async function runTaskCreate(descriptionArg?: string) {
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[]) {
let description = descriptionArg;
if (!description) {
@@ -35,6 +35,36 @@ export async function runTaskCreate(descriptionArg?: string) {
console.log(` ✓ Created ${task.id}: ${label}`);
console.log(` Column: triage`);
console.log(` Path: .hai/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
const { basename, extname, resolve } = await import("node:path");
for (const filePath of attachFiles) {
const resolvedPath = resolve(filePath);
const filename = basename(resolvedPath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
console.error(` ✗ Unsupported file type: ${ext} (${filename})`);
continue;
}
let content: Buffer;
try {
content = await readFile(resolvedPath);
} catch {
console.error(` ✗ Cannot read file: ${filePath}`);
continue;
}
const attachment = await store.addAttachment(task.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
console.log(` 📎 Attached: ${attachment.originalName} (${sizeKB} KB)`);
}
}
console.log();
}
@@ -175,6 +205,14 @@ const MIME_TYPES: Record<string, string> = {
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
export async function runTaskAttach(id: string, filePath: string) {

View File

@@ -245,10 +245,29 @@ describe("TaskStore", () => {
expect(content).toEqual(TINY_PNG);
});
it("rejects non-image mime types", async () => {
it("accepts text/plain mime type", async () => {
const task = await createTestTask();
const attachment = await store.addAttachment(task.id, "error.log", Buffer.from("log content"), "text/plain");
expect(attachment.originalName).toBe("error.log");
expect(attachment.mimeType).toBe("text/plain");
});
it("accepts application/json mime type", async () => {
const task = await createTestTask();
const attachment = await store.addAttachment(task.id, "config.json", Buffer.from('{"key":"val"}'), "application/json");
expect(attachment.mimeType).toBe("application/json");
});
it("accepts text/yaml mime type", async () => {
const task = await createTestTask();
const attachment = await store.addAttachment(task.id, "config.yaml", Buffer.from("key: val"), "text/yaml");
expect(attachment.mimeType).toBe("text/yaml");
});
it("rejects unsupported mime types", async () => {
const task = await createTestTask();
await expect(
store.addAttachment(task.id, "file.txt", Buffer.from("hello"), "text/plain"),
store.addAttachment(task.id, "file.bin", Buffer.from("data"), "application/octet-stream"),
).rejects.toThrow("Invalid mime type");
});

View File

@@ -718,6 +718,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"image/jpeg",
"image/gif",
"image/webp",
"text/plain",
"application/json",
"text/yaml",
"text/x-toml",
"text/csv",
"application/xml",
]);
private static MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; // 5MB

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TaskCard } from "./TaskCard";
import type { Task } from "@hai/core";
@@ -9,6 +9,14 @@ vi.mock("lucide-react", () => ({
Clock: () => null,
}));
// Mock the api module
vi.mock("../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
}));
import { uploadAttachment } from "../api";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "HAI-001",
@@ -63,4 +71,89 @@ describe("TaskCard", () => {
);
expect(container.querySelector(".card-status-badge")).toBeNull();
});
it("shows drop indicator on file dragover and removes on dragleave", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
const card = container.querySelector(".card")!;
// Simulate file dragover
fireEvent.dragOver(card, {
dataTransfer: { types: ["Files"], dropEffect: "none" },
});
expect(card.classList.contains("file-drop-target")).toBe(true);
// Simulate dragleave
fireEvent.dragLeave(card, {
dataTransfer: { types: ["Files"] },
});
expect(card.classList.contains("file-drop-target")).toBe(false);
});
it("does not show drop indicator for non-file drag", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
const card = container.querySelector(".card")!;
// Simulate card dragover (not files)
fireEvent.dragOver(card, {
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
});
expect(card.classList.contains("file-drop-target")).toBe(false);
});
it("calls uploadAttachment on file drop", async () => {
const mockUpload = vi.mocked(uploadAttachment);
mockUpload.mockResolvedValue({
filename: "abc-test.png",
originalName: "test.png",
mimeType: "image/png",
size: 1024,
createdAt: new Date().toISOString(),
});
const addToast = vi.fn();
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
const card = container.querySelector(".card")!;
const file = new File(["content"], "test.png", { type: "image/png" });
fireEvent.drop(card, {
dataTransfer: { types: ["Files"], files: [file] },
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("HAI-001", file);
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Attached test.png"),
"success",
);
});
});
it("shows error toast when upload fails", async () => {
const mockUpload = vi.mocked(uploadAttachment);
mockUpload.mockRejectedValue(new Error("Upload failed"));
const addToast = vi.fn();
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
const card = container.querySelector(".card")!;
const file = new File(["content"], "bad.png", { type: "image/png" });
fireEvent.drop(card, {
dataTransfer: { types: ["Files"], files: [file] },
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to attach bad.png"),
"error",
);
});
});
});

View File

@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
import { Link, Clock } from "lucide-react";
import type { Task, TaskDetail, Column } from "@hai/core";
import { fetchTaskDetail } from "../api";
import { fetchTaskDetail, uploadAttachment } from "../api";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -31,6 +31,7 @@ interface TaskCardProps {
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
const handleDragStart = useCallback((e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", task.id);
@@ -42,6 +43,42 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
setDragging(false);
}, []);
const isFileDrag = useCallback((e: React.DragEvent) => {
return e.dataTransfer.types.includes("Files");
}, []);
const handleFileDragOver = useCallback((e: React.DragEvent) => {
if (!isFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setFileDragOver(true);
}, [isFileDrag]);
const handleFileDragLeave = useCallback((e: React.DragEvent) => {
if (!isFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
setFileDragOver(false);
}, [isFileDrag]);
const handleFileDrop = useCallback(async (e: React.DragEvent) => {
if (!isFileDrag(e)) return;
e.preventDefault();
e.stopPropagation();
setFileDragOver(false);
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
try {
await uploadAttachment(task.id, file);
addToast(`Attached ${file.name} to ${task.id}`, "success");
} catch (err: any) {
addToast(`Failed to attach ${file.name}: ${err.message}`, "error");
}
}
}, [task.id, isFileDrag, addToast]);
const handleClick = useCallback(async () => {
try {
const detail = await fetchTaskDetail(task.id);
@@ -53,7 +90,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
const isFailed = task.status === "failed";
const isAgentActive = !queued && !isFailed && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${fileDragOver ? " file-drop-target" : ""}`;
return (
<div
@@ -62,6 +99,9 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
draggable={!queued}
onDragStart={queued ? undefined : handleDragStart}
onDragEnd={queued ? undefined : handleDragEnd}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
onClick={handleClick}
>
<div className="card-header">

View File

@@ -206,6 +206,10 @@ html, body {
}
.card:active { cursor: grabbing; }
.card.dragging { opacity: 0.4; transform: scale(0.98); }
.card.file-drop-target {
border: 2px dashed var(--todo);
background: rgba(88, 166, 255, 0.08);
}
/* Agent-active glow: animated border glow when an agent is actively working on a task.
Uses the in-progress column color to stay consistent with the theme. */

View File

@@ -27,13 +27,11 @@ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { TaskExecutor } from "./executor.js";
import { aiMergeTask } from "./merger.js";
import { WorktreePool } from "./worktree-pool.js";
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
import { createHaiAgent } from "./pi.js";
import { execSync } from "node:child_process";
import { findWorktreeUser } from "./merger.js";
import type { Column, Task } from "@hai/core";
import type { Column, Task, TaskDetail } from "@hai/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
@@ -622,3 +620,87 @@ describe("Merger worktree pool integration", () => {
expect(removeCalls.length).toBeGreaterThan(0);
});
});
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "HAI-001",
title: "Test Task",
description: "A test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("buildExecutionPrompt", () => {
it("includes attachment section with absolute paths for image attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc123-screenshot.png", originalName: "screenshot.png", mimeType: "image/png", size: 2048, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("## Attachments");
expect(result).toContain("**screenshot.png** (screenshot)");
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/abc123-screenshot.png");
});
it("includes attachment section with absolute paths for text attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "def456-error.log", originalName: "error.log", mimeType: "text/plain", size: 512, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("## Attachments");
expect(result).toContain("**error.log** (text/plain)");
expect(result).toContain("read for context");
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/def456-error.log");
});
it("includes both image and text attachments", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc-shot.png", originalName: "shot.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
{ filename: "def-config.json", originalName: "config.json", mimeType: "application/json", size: 256, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain("**shot.png** (screenshot)");
expect(result).toContain("**config.json** (application/json)");
});
it("omits attachment section when no attachments", () => {
const task = createMockTaskDetail({ attachments: [] });
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).not.toContain("## Attachments");
});
it("omits attachment section when attachments is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Attachments");
});
it("omits attachment section when rootDir is not provided", () => {
const task = createMockTaskDetail({
attachments: [
{ filename: "abc.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString() },
],
});
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Attachments");
});
});

View File

@@ -325,7 +325,7 @@ export class TaskExecutor {
});
try {
const agentPrompt = buildExecutionPrompt(detail);
const agentPrompt = buildExecutionPrompt(detail, this.rootDir);
await session.prompt(agentPrompt);
if (taskDone) {
@@ -569,7 +569,7 @@ export class TaskExecutor {
}
}
function buildExecutionPrompt(task: TaskDetail): string {
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string): string {
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
@@ -601,6 +601,22 @@ git log --oneline
`;
}
// Build attachments section
let attachmentsSection = "";
if (task.attachments && task.attachments.length > 0 && rootDir) {
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const lines = ["## Attachments", ""];
for (const att of task.attachments) {
const absPath = `${rootDir}/.hai/tasks/${task.id}/attachments/${att.filename}`;
if (IMAGE_MIMES.has(att.mimeType)) {
lines.push(`- **${att.originalName}** (screenshot): \`${absPath}\``);
} else {
lines.push(`- **${att.originalName}** (${att.mimeType}): \`${absPath}\` — read for context`);
}
}
attachmentsSection = "\n" + lines.join("\n") + "\n";
}
return `Execute this task.
## Task: ${task.id}
@@ -610,7 +626,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${progressSection}
${attachmentsSection}${progressSection}
## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}

View File

@@ -6,7 +6,7 @@ vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
}));
import { TriageProcessor, buildSpecificationPrompt } from "./triage.js";
import { TriageProcessor, buildSpecificationPrompt, type AttachmentContent } from "./triage.js";
import { createHaiAgent } from "./pi.js";
import type { TaskDetail } from "@hai/core";
@@ -282,4 +282,55 @@ describe("buildSpecificationPrompt", () => {
expect(result).not.toContain("## Project Commands");
});
it("includes text attachment content in fenced code block", () => {
const task = createMockTaskDetail();
const attachmentContents: AttachmentContent[] = [
{ originalName: "error.log", mimeType: "text/plain", text: "ERROR: something broke\nStack trace here" },
];
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
expect(result).toContain("## Attachments");
expect(result).toContain("### error.log (text/plain)");
expect(result).toContain("```\nERROR: something broke\nStack trace here\n```");
});
it("includes image attachment reference in prompt", () => {
const task = createMockTaskDetail();
const attachmentContents: AttachmentContent[] = [
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
];
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
expect(result).toContain("## Attachments");
expect(result).toContain("**screenshot.png** (image/png)");
expect(result).toContain("included as image below");
});
it("includes both image and text attachments", () => {
const task = createMockTaskDetail();
const attachmentContents: AttachmentContent[] = [
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
{ originalName: "config.json", mimeType: "application/json", text: '{"key": "value"}' },
];
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
expect(result).toContain("**screenshot.png** (image/png)");
expect(result).toContain("### config.json (application/json)");
expect(result).toContain('{"key": "value"}');
});
it("omits attachments section when no attachments", () => {
const task = createMockTaskDetail();
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, []);
expect(result).not.toContain("## Attachments");
});
it("omits attachments section when attachmentContents is undefined", () => {
const task = createMockTaskDetail();
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md");
expect(result).not.toContain("## Attachments");
});
});

View File

@@ -1,5 +1,6 @@
import type { TaskStore, Task, TaskDetail, Settings } from "@hai/core";
import { Type, type Static } from "@mariozechner/pi-ai";
import type { TaskStore, Task, TaskDetail, TaskAttachment, Settings } from "@hai/core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { Type } from "@mariozechner/pi-ai";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { createHaiAgent } from "./pi.js";
import type { AgentSemaphore } from "./concurrency.js";
@@ -260,8 +261,13 @@ export class TriageProcessor {
});
try {
const agentPrompt = buildSpecificationPrompt(detail, promptPath, settings);
await session.prompt(agentPrompt);
// Read attachment contents for inlining in prompt
const { attachmentContents, imageContents } = await readAttachmentContents(
this.rootDir, detail.id, detail.attachments,
);
const agentPrompt = buildSpecificationPrompt(detail, promptPath, settings, attachmentContents);
await session.prompt(agentPrompt, imageContents.length > 0 ? { images: imageContents } : undefined);
// Check if the agent flagged a duplicate
const { readFile } = await import("node:fs/promises");
@@ -372,7 +378,73 @@ export class TriageProcessor {
}
}
export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, settings?: Settings): string {
/** Content read from an attachment file for inlining in the prompt. */
export interface AttachmentContent {
originalName: string;
mimeType: string;
/** Text content for text files, null for images (handled via image content blocks). */
text: string | null;
}
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const TEXT_INLINE_LIMIT = 50 * 1024; // 50KB
/**
* Read attachment files from disk, returning text contents for inlining
* and image contents for pi image content blocks.
*/
export async function readAttachmentContents(
rootDir: string,
taskId: string,
attachments?: TaskAttachment[],
): Promise<{ attachmentContents: AttachmentContent[]; imageContents: ImageContent[] }> {
const attachmentContents: AttachmentContent[] = [];
const imageContents: ImageContent[] = [];
if (!attachments || attachments.length === 0) {
return { attachmentContents, imageContents };
}
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
for (const att of attachments) {
const filePath = join(rootDir, ".hai", "tasks", taskId, "attachments", att.filename);
try {
if (IMAGE_MIME_TYPES.has(att.mimeType)) {
const data = await readFile(filePath);
imageContents.push({
type: "image",
data: data.toString("base64"),
mimeType: att.mimeType,
});
attachmentContents.push({
originalName: att.originalName,
mimeType: att.mimeType,
text: null,
});
} else {
const data = await readFile(filePath, "utf-8");
const text = data.length > TEXT_INLINE_LIMIT
? data.slice(0, TEXT_INLINE_LIMIT) + "\n... (truncated at 50KB)"
: data;
attachmentContents.push({
originalName: att.originalName,
mimeType: att.mimeType,
text,
});
}
} catch {
// Skip unreadable attachments
continue;
}
}
return { attachmentContents, imageContents };
}
export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, settings?: Settings, attachmentContents?: AttachmentContent[]): string {
let commandsSection = "";
if (settings?.testCommand || settings?.buildCommand) {
const lines = ["## Project Commands"];
@@ -382,6 +454,20 @@ export function buildSpecificationPrompt(task: TaskDetail, promptPath: string, s
commandsSection = "\n\n" + lines.join("\n");
}
let attachmentsSection = "";
if (attachmentContents && attachmentContents.length > 0) {
const parts = ["## Attachments", ""];
for (const att of attachmentContents) {
if (att.text === null) {
// Image — will be passed via image content blocks
parts.push(`- **${att.originalName}** (${att.mimeType}) — included as image below`);
} else {
parts.push(`### ${att.originalName} (${att.mimeType})\n\n\`\`\`\n${att.text}\n\`\`\``);
}
}
attachmentsSection = "\n\n" + parts.join("\n");
}
return `Specify this task and write the result to \`${promptPath}\`.
## Task
@@ -396,5 +482,5 @@ ${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join("
3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions
4. Name actual files, functions, and patterns from the codebase — be specific
Use the write tool to write the specification file.${commandsSection}`;
Use the write tool to write the specification file.${commandsSection}${attachmentsSection}`;
}