diff --git a/apps/web/src/app/(application)/files/page.tsx b/apps/web/src/app/(application)/files/page.tsx
index 87c33b5..90d2f44 100644
--- a/apps/web/src/app/(application)/files/page.tsx
+++ b/apps/web/src/app/(application)/files/page.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useRef, useState } from "react"
+import React, { useCallback, useRef, useState } from "react"
import { toast } from "sonner"
// UI Components
@@ -354,16 +354,18 @@ const FilesPage = () => {
{breadcrumbs.map((entry, i) => (
-
+
{i > 0 && }
- {i === breadcrumbs.length - 1 ? (
- {entry.name}
- ) : (
- navigateToBreadcrumb(entry)}>
- {entry.name}
-
- )}
-
+
+ {i === breadcrumbs.length - 1 ? (
+ {entry.name}
+ ) : (
+ navigateToBreadcrumb(entry)}>
+ {entry.name}
+
+ )}
+
+
))}
diff --git a/apps/web/src/app/api/conversations/[conversationid]/artifacts/[filename]/route.ts b/apps/web/src/app/api/conversations/[conversationid]/artifacts/[filename]/route.ts
index 15f6502..8cd4e60 100644
--- a/apps/web/src/app/api/conversations/[conversationid]/artifacts/[filename]/route.ts
+++ b/apps/web/src/app/api/conversations/[conversationid]/artifacts/[filename]/route.ts
@@ -32,7 +32,9 @@ export async function GET(
// Proxy to gateway
try {
const gatewayUrl = `${GATEWAY_URL}/api/v1/sessions/${conversationid}/artifacts/${encodeURIComponent(filename)}${queryString}`
- const response = await fetch(gatewayUrl)
+ const response = await fetch(gatewayUrl, {
+ headers: { cookie: reqheaders.get("cookie") ?? "" },
+ })
if (!response.ok) {
return NextResponse.json({ error: "Artifact not found" }, { status: response.status })
diff --git a/apps/web/src/utils/file-utils.tsx b/apps/web/src/utils/file-utils.tsx
index d890120..256864a 100644
--- a/apps/web/src/utils/file-utils.tsx
+++ b/apps/web/src/utils/file-utils.tsx
@@ -45,7 +45,8 @@ export const isPreviewable = (mediatype: string): boolean => {
mediatype === "text/plain" ||
mediatype === "text/markdown" ||
mediatype === "text/csv" ||
- mediatype === "application/json"
+ mediatype === "application/json" ||
+ mediatype === "application/pdf"
)
}
diff --git a/infra/openshell/Dockerfile b/infra/openshell/Dockerfile
index 8de6e9e..9395f9c 100644
--- a/infra/openshell/Dockerfile
+++ b/infra/openshell/Dockerfile
@@ -17,10 +17,28 @@
# =============================================================================
# ---------------------------------------------------------------------------
-# Stage 1: Build sandbox-server and produce a deployable bundle
+# Stage 1: Build sandbox-server and produce a deployable bundle.
+# Also pre-builds all native npm globals here so the runtime stage does not
+# need a C/C++ toolchain.
# ---------------------------------------------------------------------------
FROM node:22-slim AS builder
+# Build-time system dependencies:
+# - build-essential / g++ / make / pkg-config: required by node-gyp for native modules
+# - *-dev packages: header files needed to compile canvas (cairo, gif, jpeg, pango, rsvg)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ g++ \
+ libcairo2-dev \
+ libgif-dev \
+ libjpeg-dev \
+ libpango1.0-dev \
+ librsvg2-dev \
+ make \
+ pkg-config \
+ python3 \
+ && rm -rf /var/lib/apt/lists/*
+
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /build
@@ -53,35 +71,66 @@ RUN pnpm --filter @openzosma/sandbox-server deploy /deploy --prod --legacy
# Copy built dist into the deploy directory
RUN cp -r packages/sandbox-server/dist /deploy/dist
+# Pre-build all global npm packages that contain native modules or are large.
+# Installing here (with the toolchain present) means the runtime stage only
+# needs to COPY the pre-compiled /usr/local/lib/node_modules directory —
+# no compiler required at runtime.
+RUN npm install -g \
+ @mariozechner/pi-coding-agent \
+ agent-slack \
+ chart.js \
+ chartjs-node-canvas \
+ d3 \
+ exceljs
+
# ---------------------------------------------------------------------------
# Stage 2: Runtime
# ---------------------------------------------------------------------------
FROM node:22-slim
-# Install runtime dependencies and developer tools.
-# iproute2 is required by the OpenShell sandbox supervisor for network namespace creation.
-# python3-venv provides virtualenv support for isolated Python environments.
-# tree, jq, wget, less, unzip, zip are general-purpose CLI tools the agent can use.
-# make + g++ are required for native npm modules that use node-gyp.
-# Node.js and npm are already present from the node:22-slim base image.
+# Runtime system dependencies only — no compiler toolchain, no *-dev headers.
+#
+# Shared libraries required by pre-compiled native modules (e.g. canvas):
+# libcairo2, libgif7, libjpeg62-turbo, libpango-1.0-0, libpangocairo-1.0-0,
+# librsvg2-2 — these are the runtime counterparts of the *-dev packages used
+# in the builder stage.
+#
+# iproute2: required by the OpenShell sandbox supervisor for network namespace creation.
+# python3 / python3-pip / python3-venv: agent-generated Python code + report generation.
+# tini: PID 1 / signal handling.
+# git, curl, wget, jq, less, tree, unzip, zip: general-purpose CLI tools for the agent.
RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
git \
+ iproute2 \
+ jq \
+ less \
+ libcairo2 \
+ libgif7 \
+ libjpeg62-turbo \
+ libpango-1.0-0 \
+ libpangocairo-1.0-0 \
+ librsvg2-2 \
python3 \
python3-pip \
python3-venv \
tini \
- curl \
- iproute2 \
tree \
- jq \
- wget \
- less \
unzip \
+ wget \
zip \
- make \
- g++ \
&& rm -rf /var/lib/apt/lists/*
+# Install Python reporting libraries
+RUN pip3 install --break-system-packages \
+ matplotlib \
+ numpy \
+ openpyxl \
+ pandas \
+ python-pptx \
+ reportlab \
+ seaborn
+
# Create sandbox user (OpenShell convention: non-root execution)
# --home is required so homedir() returns a real path; bootstrapPiExtensions()
# writes config files to ~/.pi/ and will crash if home is /nonexistent.
@@ -95,11 +144,12 @@ WORKDIR /app
# Copy the self-contained deploy bundle (node_modules + dist, no symlinks)
COPY --from=builder /deploy ./
-RUN npm install -g @mariozechner/pi-coding-agent
-
-# Install agent-slack CLI so the agent can query Slack from inside the sandbox.
-# The CLI reads SLACK_TOKEN from the environment (injected via .env by the orchestrator).
-RUN npm install -g agent-slack
+# Copy pre-compiled global npm packages from the builder stage.
+# All native modules (e.g. canvas inside chartjs-node-canvas) were compiled
+# there with the full toolchain. The runtime stage needs only the shared libs
+# (installed above), not the compiler.
+COPY --from=builder /usr/local/lib/node_modules /usr/local/lib/node_modules
+COPY --from=builder /usr/local/bin /usr/local/bin
# Copy extension manifest and install script before switching to sandbox user
COPY packages/agents/extensions.json /tmp/extensions.json
@@ -123,7 +173,7 @@ RUN chown root:root -R ./skills/ && chmod 444 -R ./skills/
# In per-user persistent sandboxes, this directory persists across sessions.
# /sandbox is the OpenShell writable root where .env is uploaded.
# .knowledge-base is pre-created so the agent prompt doesn't error on ls.
-RUN mkdir -p /workspace/.knowledge-base /tmp/agent /sandbox && \
+RUN mkdir -p /workspace/.knowledge-base /workspace/output /tmp/agent /sandbox && \
chown -R sandbox:sandbox /workspace /tmp/agent /sandbox
# Copy entrypoint into /app/ which is already in the policy read_only allowlist.
diff --git a/packages/agents/package.json b/packages/agents/package.json
index bd1add9..655066b 100644
--- a/packages/agents/package.json
+++ b/packages/agents/package.json
@@ -29,6 +29,7 @@
"uuid": "^13.0.0",
"@openzosma/db": "workspace:*",
"@openzosma/integrations": "workspace:*",
+ "@openzosma/skill-reports": "workspace:*",
"pg": "^8.13.1",
"@mariozechner/pi-agent-core": "^0.61.0"
},
diff --git a/packages/agents/src/pi.agent.ts b/packages/agents/src/pi.agent.ts
index dca2810..b5d76a6 100644
--- a/packages/agents/src/pi.agent.ts
+++ b/packages/agents/src/pi.agent.ts
@@ -11,7 +11,12 @@ import { createLogger } from "@openzosma/logger"
import { bootstrapMemory } from "@openzosma/memory"
import { DEFAULT_SYSTEM_PROMPT } from "./pi/config.js"
import { resolveModel } from "./pi/model.js"
-import { createDefaultTools, createListDatabaseSchemasTool, createQueryDatabaseTool } from "./pi/tools.js"
+import {
+ createDefaultTools,
+ createListDatabaseSchemasTool,
+ createQueryDatabaseTool,
+ createReportTools,
+} from "./pi/tools.js"
import type { AgentMessage, AgentProvider, AgentSession, AgentSessionOpts, AgentStreamEvent } from "./types.js"
const log = createLogger({ component: "agents" })
@@ -52,9 +57,11 @@ class PiAgentSession implements AgentSession {
memoryDir: opts.memoryDir,
})
const toolList = [...createDefaultTools(opts.workspaceDir, opts.toolsEnabled)]
- const customTools = opts.dbPool
- ? [createQueryDatabaseTool(opts.dbPool), createListDatabaseSchemasTool(opts.dbPool)]
- : undefined
+ const reportTools = createReportTools(opts.toolsEnabled, opts.workspaceDir)
+ const customTools = [
+ ...reportTools,
+ ...(opts.dbPool ? [createQueryDatabaseTool(opts.dbPool), createListDatabaseSchemasTool(opts.dbPool)] : []),
+ ]
const { model, apiKey } = resolveModel({
provider: opts.provider,
model: opts.model,
diff --git a/packages/agents/src/pi/tools.ts b/packages/agents/src/pi/tools.ts
index 5f6c960..d059e6e 100644
--- a/packages/agents/src/pi/tools.ts
+++ b/packages/agents/src/pi/tools.ts
@@ -12,21 +12,75 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent"
import { integrationQueries } from "@openzosma/db"
import type { IntegrationConfig } from "@openzosma/db"
import { executequery, getschema, safeDecrypt } from "@openzosma/integrations"
+import {
+ createReportExecuteCodeTool,
+ createReportGenerateTool,
+ createReportListTemplatesTool,
+} from "@openzosma/skill-reports"
import { Type } from "@sinclair/typebox"
import type pg from "pg"
-export type BuiltInToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"
+export type BuiltInToolName =
+ | "read"
+ | "bash"
+ | "edit"
+ | "write"
+ | "grep"
+ | "find"
+ | "ls"
+ | "report_list_templates"
+ | "report_generate"
+ | "report_execute_code"
+
+// Built-in AgentTool entries (accepted by createAgentSession's `tools` field).
+const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const
+type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number]
+
+// Custom ToolDefinition entries (accepted by createAgentSession's `customTools` field).
+const CUSTOM_TOOL_NAMES = ["report_list_templates", "report_generate", "report_execute_code"] as const
+type CustomToolName = (typeof CUSTOM_TOOL_NAMES)[number]
+/**
+ * Create the built-in AgentTool instances (read, bash, edit, write, grep, find, ls).
+ * These go in `tools` when calling createAgentSession.
+ *
+ * @param workspaceDir - Root workspace directory.
+ * @param toolsEnabled - Optional allow-list of tool names. If omitted, all tools are returned.
+ */
export const createDefaultTools = (workspaceDir: string, toolsEnabled?: string[]) => {
const allTools = [
- { name: "read", tool: createReadTool(workspaceDir) },
- { name: "bash", tool: createBashTool(workspaceDir) },
- { name: "edit", tool: createEditTool(workspaceDir) },
- { name: "write", tool: createWriteTool(workspaceDir) },
- { name: "grep", tool: createGrepTool(workspaceDir) },
- { name: "find", tool: createFindTool(workspaceDir) },
- { name: "ls", tool: createLsTool(workspaceDir) },
- ] as const
+ { name: "read" as BuiltinToolName, tool: createReadTool(workspaceDir) },
+ { name: "bash" as BuiltinToolName, tool: createBashTool(workspaceDir) },
+ { name: "edit" as BuiltinToolName, tool: createEditTool(workspaceDir) },
+ { name: "write" as BuiltinToolName, tool: createWriteTool(workspaceDir) },
+ { name: "grep" as BuiltinToolName, tool: createGrepTool(workspaceDir) },
+ { name: "find" as BuiltinToolName, tool: createFindTool(workspaceDir) },
+ { name: "ls" as BuiltinToolName, tool: createLsTool(workspaceDir) },
+ ]
+
+ if (!toolsEnabled || toolsEnabled.length === 0) {
+ return allTools.map((t) => t.tool)
+ }
+
+ const allow = new Set(toolsEnabled)
+ return allTools.filter((t) => allow.has(t.name)).map((t) => t.tool)
+}
+
+/**
+ * Create the report ToolDefinition instances (report_list_templates, report_generate, report_execute_code).
+ * These go in `customTools` when calling createAgentSession.
+ *
+ * @param toolsEnabled - Optional allow-list of tool names. If omitted, all report tools are returned.
+ * @param workspaceDir - Workspace root; report output will go to /output.
+ * Defaults to /workspace/output when omitted (sandbox mode).
+ */
+export const createReportTools = (toolsEnabled?: string[], workspaceDir?: string): ToolDefinition[] => {
+ const outputDir = workspaceDir ? `${workspaceDir}/output` : undefined
+ const allTools = [
+ { name: "report_list_templates" as CustomToolName, tool: createReportListTemplatesTool({ outputDir }) },
+ { name: "report_generate" as CustomToolName, tool: createReportGenerateTool({ outputDir }) },
+ { name: "report_execute_code" as CustomToolName, tool: createReportExecuteCodeTool({ outputDir }) },
+ ]
if (!toolsEnabled || toolsEnabled.length === 0) {
return allTools.map((t) => t.tool)
diff --git a/packages/gateway/src/app.ts b/packages/gateway/src/app.ts
index 7acf6fd..172d0e0 100644
--- a/packages/gateway/src/app.ts
+++ b/packages/gateway/src/app.ts
@@ -1,4 +1,6 @@
import { createHash, randomBytes } from "node:crypto"
+import { createReadStream, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"
+import { join, resolve } from "node:path"
import { buildDefaultAgentCard } from "@openzosma/a2a"
import type { Auth } from "@openzosma/auth"
import type { Role } from "@openzosma/auth"
@@ -79,10 +81,173 @@ export const createApp = (
// File management routes (require orchestrator for sandbox filesystem access)
// -----------------------------------------------------------------------
+ // MIME types for agent-generated artifacts (used in local-mode file routes
+ // and the session artifact routes below).
+ const ARTIFACT_MIME_MAP: Record = {
+ png: "image/png",
+ svg: "image/svg+xml",
+ pdf: "application/pdf",
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ csv: "text/csv",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ txt: "text/plain",
+ json: "application/json",
+ }
+
if (orchestrator) {
app.route("/api/v1/files", createFileRoutes({ orchestrator }))
+ } else {
+ // Local mode: serve files from workspace/user-files/ai-generated/.
+ // Files are copied here by scanOutputDir whenever the agent generates an artifact.
+
+ const localUserFilesDir = (): string => {
+ const root = resolve(process.env.OPENZOSMA_WORKSPACE ?? join(process.cwd(), "workspace"))
+ const dir = join(root, "user-files", "ai-generated")
+ mkdirSync(dir, { recursive: true })
+ return dir
+ }
+
+ const LOCAL_MIME_MAP: Record = {
+ png: "image/png",
+ svg: "image/svg+xml",
+ pdf: "application/pdf",
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ csv: "text/csv",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ txt: "text/plain",
+ json: "application/json",
+ }
+
+ const scanLocalFiles = (dir: string) =>
+ existsSync(dir)
+ ? readdirSync(dir)
+ .filter((f) => LOCAL_MIME_MAP[f.split(".").pop() ?? ""])
+ .map((filename) => {
+ const ext = filename.split(".").pop() ?? ""
+ return {
+ name: filename,
+ path: `/ai-generated/${filename}`,
+ isFolder: false,
+ mimeType: LOCAL_MIME_MAP[ext] ?? "application/octet-stream",
+ sizeBytes: statSync(join(dir, filename)).size,
+ modifiedAt: statSync(join(dir, filename)).mtime.toISOString(),
+ }
+ })
+ : []
+
+ app.get("/api/v1/files/tree", requirePermission("files", "read"), (c) => {
+ const dir = localUserFilesDir()
+ const files = scanLocalFiles(dir)
+ const entries =
+ files.length > 0
+ ? [
+ {
+ name: "ai-generated",
+ path: "/ai-generated",
+ isFolder: true,
+ mimeType: null,
+ sizeBytes: 0,
+ modifiedAt: new Date().toISOString(),
+ children: files,
+ },
+ ]
+ : []
+ return c.json({ entries })
+ })
+
+ app.get("/api/v1/files/list", requirePermission("files", "read"), (c) => {
+ const dir = localUserFilesDir()
+ return c.json({ entries: scanLocalFiles(dir) })
+ })
+
+ app.get("/api/v1/files/download", requirePermission("files", "read"), (c) => {
+ const filePath = c.req.query("path")
+ if (!filePath) return c.json({ error: "path query parameter is required" }, 400)
+
+ const dir = localUserFilesDir()
+ // path is like /ai-generated/filename.pdf — strip the folder prefix
+ const filename = filePath.replace(/^\/ai-generated\//, "")
+ const fullPath = join(dir, filename)
+
+ if (!existsSync(fullPath)) return c.json({ error: "File not found" }, 404)
+
+ const ext = filename.split(".").pop() ?? ""
+ const contentType = LOCAL_MIME_MAP[ext] ?? "application/octet-stream"
+ const stat = statSync(fullPath)
+ const download = c.req.query("download") === "true"
+
+ c.header("Content-Type", contentType)
+ c.header("Content-Length", String(stat.size))
+ c.header("Cache-Control", "private, max-age=3600")
+ if (download) c.header("Content-Disposition", `attachment; filename="${filename}"`)
+
+ const stream = createReadStream(fullPath)
+ return c.body(stream as unknown as ReadableStream)
+ })
}
+ // -----------------------------------------------------------------------
+ // Session artifact routes (local mode — serve files from session output dir)
+ // -----------------------------------------------------------------------
+
+ app.get("/api/v1/sessions/:id/artifacts/:filename", requirePermission("sessions", "read"), (c) => {
+ const sessionId = c.req.param("id")
+ const filename = c.req.param("filename")
+
+ const workspaceDir = sessionManager.getSessionWorkspaceDir(sessionId)
+ if (!workspaceDir) {
+ return c.json({ error: "Session not found or not in local mode" }, 404)
+ }
+
+ const filePath = join(workspaceDir, "output", filename)
+ if (!existsSync(filePath)) {
+ return c.json({ error: "Artifact not found" }, 404)
+ }
+
+ const stat = statSync(filePath)
+ const ext = filename.split(".").pop() ?? ""
+ const contentType = ARTIFACT_MIME_MAP[ext] ?? "application/octet-stream"
+ const download = c.req.query("download") === "true"
+
+ c.header("Content-Type", contentType)
+ c.header("Content-Length", String(stat.size))
+ c.header("Cache-Control", "private, max-age=3600")
+ if (download) {
+ c.header("Content-Disposition", `attachment; filename="${filename}"`)
+ }
+
+ // Stream the file to avoid loading it fully into memory
+ const stream = createReadStream(filePath)
+ return c.body(stream as unknown as ReadableStream)
+ })
+
+ app.get("/api/v1/sessions/:id/artifacts", requirePermission("sessions", "read"), (c) => {
+ const sessionId = c.req.param("id")
+ const workspaceDir = sessionManager.getSessionWorkspaceDir(sessionId)
+ if (!workspaceDir) {
+ return c.json({ artifacts: [] })
+ }
+
+ const outputDir = join(workspaceDir, "output")
+ if (!existsSync(outputDir)) {
+ return c.json({ artifacts: [] })
+ }
+
+ const TRACKED_EXTS = new Set(Object.keys(ARTIFACT_MIME_MAP))
+ const artifacts = readdirSync(outputDir)
+ .filter((f) => TRACKED_EXTS.has(f.split(".").pop() ?? ""))
+ .map((filename) => {
+ const ext = filename.split(".").pop() ?? ""
+ return {
+ filename,
+ mediatype: ARTIFACT_MIME_MAP[ext] ?? "application/octet-stream",
+ sizebytes: statSync(join(outputDir, filename)).size,
+ }
+ })
+
+ return c.json({ artifacts })
+ })
+
// -----------------------------------------------------------------------
// Session routes
// -----------------------------------------------------------------------
diff --git a/packages/gateway/src/session-manager.ts b/packages/gateway/src/session-manager.ts
index 590f25e..0bacf64 100644
--- a/packages/gateway/src/session-manager.ts
+++ b/packages/gateway/src/session-manager.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
-import { mkdirSync, symlinkSync, writeFileSync } from "node:fs"
+import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import type { AgentProvider, AgentSession } from "@openzosma/agents"
import { PiAgentProvider } from "@openzosma/agents"
@@ -241,6 +241,14 @@ export class SessionManager {
return this.sessions.get(id)?.session
}
+ /**
+ * Return the workspace directory for a local-mode session, or undefined
+ * when running in orchestrator mode or the session does not exist.
+ */
+ getSessionWorkspaceDir(id: string): string | undefined {
+ return this.sessions.get(id)?.workspaceDir
+ }
+
deleteSession(id: string): boolean {
if (this.orchestrator) {
const session = this.sessions.get(id)
@@ -578,6 +586,8 @@ export class SessionManager {
let lastAssistantText = ""
let lastMessageId: string | undefined
const emitter = this.getEmitter(sessionId)
+ // Track files already seen so we only emit each artifact once per message.
+ const seenOutputFiles = new Set()
for await (const event of agentSession.sendMessage(augmentedContent, signal)) {
const gatewayEvent: GatewayEvent = event as GatewayEvent
@@ -591,6 +601,17 @@ export class SessionManager {
emitter.emit("event", gatewayEvent)
yield gatewayEvent
+
+ // After each tool call completes, scan the output directory for new files
+ // and emit a file_output event so the frontend can display download links.
+ if (event.type === "tool_call_end") {
+ const newArtifacts = this.scanOutputDir(workspaceDir, seenOutputFiles)
+ if (newArtifacts.length > 0) {
+ const fileEvent: GatewayEvent = { type: "file_output", artifacts: newArtifacts }
+ emitter.emit("event", fileEvent)
+ yield fileEvent
+ }
+ }
}
// Store assistant message for session history
@@ -605,6 +626,53 @@ export class SessionManager {
}
}
+ /**
+ * Scan workspaceDir/output for files and return FileArtifact metadata.
+ * Used in local mode to emit file_output events after tool calls.
+ */
+ private scanOutputDir(workspaceDir: string, seenFiles: Set): FileArtifact[] {
+ const outputDir = join(workspaceDir, "output")
+ if (!existsSync(outputDir)) return []
+
+ const MIME_MAP: Record = {
+ png: "image/png",
+ svg: "image/svg+xml",
+ pdf: "application/pdf",
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ csv: "text/csv",
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ }
+ const TRACKED_EXTS = new Set(Object.keys(MIME_MAP))
+
+ // Copy new artifacts to the central user-files area so the Files page can list them
+ const workspaceRoot = resolve(process.env.OPENZOSMA_WORKSPACE ?? join(process.cwd(), "workspace"))
+ const userFilesDir = join(workspaceRoot, "user-files", "ai-generated")
+ mkdirSync(userFilesDir, { recursive: true })
+
+ const newArtifacts: FileArtifact[] = []
+ for (const filename of readdirSync(outputDir)) {
+ if (seenFiles.has(filename)) continue
+ const ext = filename.split(".").pop() ?? ""
+ if (!TRACKED_EXTS.has(ext)) continue
+ const filepath = join(outputDir, filename)
+ const sizebytes = statSync(filepath).size
+ seenFiles.add(filename)
+ newArtifacts.push({
+ filename,
+ mediatype: MIME_MAP[ext] ?? "application/octet-stream",
+ sizebytes,
+ })
+
+ // Non-fatal: artifact card in chat still works via the session artifact route
+ try {
+ copyFileSync(filepath, join(userFilesDir, filename))
+ } catch {
+ /* ignore */
+ }
+ }
+ return newArtifacts
+ }
+
/**
* Decode a data URL into a Buffer and MIME type.
*
diff --git a/packages/skills/reports/package.json b/packages/skills/reports/package.json
index d4c1c62..eb354ec 100644
--- a/packages/skills/reports/package.json
+++ b/packages/skills/reports/package.json
@@ -13,10 +13,24 @@
},
"scripts": {
"build": "tsc",
- "check": "tsc --noEmit"
+ "check": "tsc --noEmit",
+ "test": "vitest --run"
+ },
+ "dependencies": {
+ "@mariozechner/pi-agent-core": "^0.61.0",
+ "@mariozechner/pi-coding-agent": "^0.61.0",
+ "@react-pdf/renderer": "^4.3.0",
+ "@sinclair/typebox": "^0.34.48",
+ "chart.js": "^4.4.9",
+ "chartjs-node-canvas": "^4.1.6",
+ "exceljs": "^4.4.0",
+ "pptxgenjs": "^3.12.0",
+ "react": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.2",
- "typescript": "^5.7.3"
+ "@types/react": "^19.1.2",
+ "typescript": "^5.7.3",
+ "vitest": "^3.1.1"
}
}
diff --git a/packages/skills/reports/src/index.ts b/packages/skills/reports/src/index.ts
index 755e155..fc00e44 100644
--- a/packages/skills/reports/src/index.ts
+++ b/packages/skills/reports/src/index.ts
@@ -1,3 +1,14 @@
-// Reports Skill - Template-based and agent-generated report creation.
-// Implementation in Phase 6.
-export {}
+// Reports Skill — template-based and agent-generated report creation.
+export { createReportListTemplatesTool, createReportGenerateTool, createReportExecuteCodeTool } from "./tools.js"
+export type {
+ MonthlyReportData,
+ MetricRow,
+ ChartDefinition,
+ ChartDataset,
+ TableDefinition,
+ RenderOpts,
+ ReportFormat,
+ ReportTemplate,
+} from "./templates/types.js"
+export { MonthlyReportDataSchema, MonthlyReportTemplate } from "./templates/monthly-report.js"
+export { getTemplate, listTemplates, registerTemplate } from "./templates/registry.js"
diff --git a/packages/skills/reports/src/renderers/chart.test.ts b/packages/skills/reports/src/renderers/chart.test.ts
new file mode 100644
index 0000000..7ad1355
--- /dev/null
+++ b/packages/skills/reports/src/renderers/chart.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+// Mock chartjs-node-canvas before importing renderChart so the native canvas
+// binary is never loaded. CI runners do not have the system libraries (Cairo,
+// libpng) required to compile canvas@2.x, causing a hard module-not-found
+// error when the real binding is required.
+vi.mock("chartjs-node-canvas", () => {
+ const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+ const ChartJSNodeCanvas = vi.fn().mockImplementation(() => ({
+ renderToBuffer: vi.fn().mockResolvedValue(PNG_HEADER),
+ renderToBufferSync: vi.fn().mockReturnValue(Buffer.from("", "utf-8")),
+ }))
+
+ return { ChartJSNodeCanvas }
+})
+
+import { renderChart } from "./chart.js"
+
+describe("renderChart", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("renders a bar chart and returns a Buffer with PNG magic bytes", async () => {
+ const buf = await renderChart({
+ type: "bar",
+ title: "Test Bar Chart",
+ labels: ["Jan", "Feb", "Mar"],
+ datasets: [
+ {
+ label: "Sessions",
+ data: [10, 20, 15],
+ backgroundColor: "rgba(54, 162, 235, 0.5)",
+ },
+ ],
+ })
+
+ expect(buf).toBeInstanceOf(Buffer)
+ // PNG magic bytes: \x89PNG
+ expect(buf[0]).toBe(0x89)
+ expect(buf[1]).toBe(0x50) // P
+ expect(buf[2]).toBe(0x4e) // N
+ expect(buf[3]).toBe(0x47) // G
+ })
+
+ it("renders a line chart", async () => {
+ const buf = await renderChart({
+ type: "line",
+ title: "Line Chart",
+ labels: ["Q1", "Q2"],
+ datasets: [{ label: "Value", data: [5, 10] }],
+ })
+ expect(buf).toBeInstanceOf(Buffer)
+ expect(buf.length).toBeGreaterThan(0)
+ })
+
+ it("renders a pie chart", async () => {
+ const buf = await renderChart({
+ type: "pie",
+ title: "Pie Chart",
+ labels: ["A", "B"],
+ datasets: [{ label: "Share", data: [60, 40] }],
+ })
+ expect(buf).toBeInstanceOf(Buffer)
+ expect(buf.length).toBeGreaterThan(0)
+ })
+
+ it("renders an SVG when format is svg", async () => {
+ const buf = await renderChart(
+ {
+ type: "bar",
+ title: "SVG Chart",
+ labels: ["X"],
+ datasets: [{ label: "Y", data: [1] }],
+ },
+ "svg",
+ )
+ expect(buf).toBeInstanceOf(Buffer)
+ expect(buf.toString()).toContain("