feat: ship agent onboarding runtime

This commit is contained in:
-Puter
2026-08-04 01:38:48 +05:30
parent 48205a5e19
commit b02200b3dc
68 changed files with 10522 additions and 4156 deletions
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@flue/runtime/config";
// Flue 2.0 project configuration.
// - target: 'node' builds a long-running Node HTTP server.
// - app: the route map (src/app.ts) — health + private internal routes + agent router.
// - db: file-backed SQLite persistence (src/db.ts) for canonical conversation state.
// - agents: scoped to src/agents/ for the 'use agent' scan.
export default defineConfig({
agents: "agents/**/*.ts",
app: "src/app.ts",
db: "src/db.ts",
// Custom providers are registered at runtime via setProvider() in app.ts.
// Empty list = register none from the built-in catalog; the Cheaptricks
// provider is wired programmatically.
providers: [],
target: "node",
});
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@code/agents",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check-types": "tsc --noEmit",
"start": "node dist/server.mjs"
},
"dependencies": {
"@agentos-software/common": "catalog:",
"@agentos-software/git": "catalog:",
"@code/env": "workspace:*",
"@earendil-works/pi-ai": "catalog:",
"@flue/runtime": "catalog:",
"@flue/sdk": "catalog:",
"@hono/node-server": "catalog:",
"@rivet-dev/agentos": "catalog:",
"convex": "catalog:",
"hono": "catalog:",
"rivetkit": "catalog:",
"valibot": "^1.0.0",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "catalog:",
"@flue/vite": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
}
}
+469
View File
@@ -0,0 +1,469 @@
import path from "node:path";
import { env } from "@code/env/agent";
import type { CodeExecutionResult } from "@rivet-dev/agentos";
import { createClient } from "@rivet-dev/agentos/client";
import type { registry } from "../runner";
// AgentOS adapter: resolves one project workspace actor keyed structurally
// by projectId, and exposes only safe read-oriented operations for the
// onboarding runtime endpoint and the conversation agent's tools.
//
// The adapter talks to the durable AgentOS RivetKit actor registered in this
// same process (src/runner.ts, served in serverless mode via the Hono app).
// It caches only the lightweight actor client, never VM handles — so it
// survives engine restarts and process recycling. Each operation re-resolves
// the actor handle via getOrCreate + resolve, which is stateless and safe
// across lifecycle boundaries.
//
// It does NOT use Pi for onboarding — Pi capability is preserved only for
// later GLM-5.2 work, and this module never invokes it.
/**
* Repository path inside a project VM.
* All repos clone to /workspace/repo inside the VM.
*/
export const REPO_PATH = "/workspace/repo";
export interface ProjectVm {
vmId: string;
workspacePath: string;
repositoryPath: string;
}
export interface DirListEntry {
name: string;
kind: "file" | "directory" | "symlink";
size: number;
}
export interface RepoDescription {
commit: string;
rootEntries: DirListEntry[];
}
export interface SearchResult {
path: string;
snippet: string;
}
// ---------------------------------------------------------------------------
// Safe-command policy for execSafe.
// Only static inspected command names are permitted; shell strings are
// rejected. Arguments are passed as an array (no shell interpolation).
// ---------------------------------------------------------------------------
const ALLOWED_COMMANDS: ReadonlyMap<string, readonly string[]> = new Map([
// Git read-only.
[
"git",
[
"rev-parse",
"log",
"status",
"ls-files",
"remote",
"branch",
"describe",
"show",
],
],
// Text inspection.
["grep", ["-r", "-rn", "-i", "--include", "-l"]],
["find", ["-name", "-type", "-maxdepth", "-mindepth"]],
["wc", ["-l", "-w", "-c"]],
["head", ["-n", "-c"]],
["tail", ["-n", "-c"]],
["cat", []],
["ls", ["-la", "-l", "-a"]],
]);
/**
* Validate that a command is statically permitted and that its args are a
* safe list (no shell metacharacters, no strings). This is the only gate
* between the model and process execution.
*/
export const validateSafeCommand = (
command: string,
args: readonly string[]
): void => {
if (typeof command !== "string" || command.length === 0) {
throw new Error("execSafe requires a non-empty command name");
}
// Reject anything that looks like a shell string (spaces, pipes, redirects).
if (/\s|[;&|<>`$()]/u.test(command)) {
throw new Error(`execSafe rejects shell expression: ${command}`);
}
// Resolve the command to its base name (no path traversal through /bin/...).
const base = path.basename(command);
const allowedFlags = ALLOWED_COMMANDS.get(base);
if (!allowedFlags) {
throw new Error(`execSafe command not permitted: ${base}`);
}
for (const arg of args) {
if (typeof arg !== "string") {
throw new TypeError("execSafe args must all be strings");
}
// Reject shell metacharacters in arguments.
if (/[;&|<>`$()]/u.test(arg)) {
throw new Error(`execSafe arg contains shell metacharacters: ${arg}`);
}
// Flags must be in the allowlist (starts with -). Non-flag args (paths,
// patterns, numbers) pass through — they cannot invoke a shell.
if (
arg.startsWith("-") &&
!allowedFlags.includes(arg) &&
!arg.startsWith("--include=")
) {
throw new Error(`execSafe flag not permitted for ${base}: ${arg}`);
}
}
};
/**
* Prevent path traversal: resolve a path against the repository root and
* ensure the result stays inside it. Rejects absolute paths escaping the
* root and ../ sequences.
*/
export const safeRepoPath = (
repoRoot: string,
relativePath: string
): string => {
// Normalize and strip leading slashes so "/etc/passwd" can't escape.
const cleaned = relativePath.replace(/^\/+/u, "");
const resolved = path.resolve(repoRoot, cleaned);
const normalizedRoot = path.resolve(repoRoot);
if (
resolved !== normalizedRoot &&
!resolved.startsWith(normalizedRoot + path.sep)
) {
throw new Error(
`Path traversal rejected: ${relativePath} escapes repository root`
);
}
return resolved;
};
// ---------------------------------------------------------------------------
// Actor client.
//
// The client is the only durable handle the adapter caches. Actor (VM)
// handles are resolved per-operation through getOrCreate + resolve, so the
// adapter is stateless with respect to VM lifecycle and survives process
// recycling.
// ---------------------------------------------------------------------------
/**
* The public actor client surface used by this adapter. The actor handle is
* stateless: every action resolves it against the engine as needed.
*/
interface ProjectWorkspaceClient {
projectWorkspace: {
getOrCreate: (
key: readonly [projectId: string],
options: { params: { token: string } }
) => {
resolve: () => Promise<string>;
process: {
execFile: (
command: string,
args?: readonly string[]
) => Promise<CodeExecutionResult>;
};
filesystem: {
exists: (target: string) => Promise<boolean>;
readdirEntries: (target: string) => Promise<readonly DirStatEntry[]>;
readFile: (target: string) => Promise<Uint8Array>;
mkdir: (
target: string,
options?: { recursive?: boolean }
) => Promise<void>;
writeFile: (
target: string,
content: string | Uint8Array
) => Promise<void>;
};
};
};
}
/** Entry returned by filesystem.readdirEntries. */
interface DirStatEntry {
name: string;
isDirectory: boolean;
isSymbolicLink: boolean;
size: number;
}
/**
* Lazily-created actor client. Cached for the process lifetime; the client
* object is safe to reuse, unlike VM handles which are lifecycle-bound.
*/
let clientCache: ProjectWorkspaceClient | undefined;
const getClient = (): ProjectWorkspaceClient => {
if (!clientCache) {
clientCache = createClient<typeof registry>({
disableMetadataLookup: true,
encoding: "cbor",
endpoint: env.RIVET_ENDPOINT,
}) as unknown as ProjectWorkspaceClient;
}
return clientCache;
};
/**
* Resolve the durable actor handle for a project. The actor is keyed
* structurally by [projectId] (project ids are globally unique). The handle
* is stateless — it re-resolves across lifecycle boundaries — so it is never
* cached.
*/
const getProjectHandle = (projectId: string) =>
getClient().projectWorkspace.getOrCreate([projectId], {
params: { token: env.RIVET_WORKSPACE_TOKEN },
});
/**
* Throw a descriptive Error when an execFile result did not succeed.
* Surfaces the operation, outcome, and available stdout/stderr/error.
*/
const requireExecSuccess = (
result: CodeExecutionResult,
operation: string
): void => {
if (result.outcome !== "succeeded") {
const detail = [result.stderr, result.stdout, result.error.message]
.filter(
(part): part is string => typeof part === "string" && part.length > 0
)
.join(" | ");
throw new Error(
`${operation} failed (outcome=${result.outcome})${detail ? `: ${detail}` : ""}`
);
}
};
/**
* AgentOS owns the actor filesystem. The repository has no host workspace
* projection, so persist its actual in-VM path rather than an invented path.
*/
const workspacePath = (): string => REPO_PATH;
/**
* Get or create a project workspace VM. Idempotent by projectId — the actor
* is keyed structurally by [projectId] and resolved on demand.
*
* The vmId/runtimeId is the actor's resolved id, which remains stable across
* re-resolution.
*/
export const getOrCreateProjectVm = async (
projectId: string
): Promise<ProjectVm> => {
const handle = getProjectHandle(projectId);
const vmId = await handle.resolve();
return {
repositoryPath: REPO_PATH,
vmId,
workspacePath: workspacePath(),
};
};
/**
* Read the current HEAD commit. Used for readability verification and
* ProjectRuntime.repositoryCommit.
*/
export const readRepositoryHead = async (
projectId: string
): Promise<{ commit: string }> => {
const handle = getProjectHandle(projectId);
const result = await handle.process.execFile("git", [
"-C",
REPO_PATH,
"rev-parse",
"HEAD",
]);
requireExecSuccess(result, "git rev-parse HEAD");
return { commit: (result.stdout ?? "").trim() };
};
/**
* Depth-1 branch clone into the project VM. Idempotent: if the repository
* directory already exists, the clone is skipped (assuming a prior successful
* clone). Credentials are never exposed to the model.
*/
export const cloneRepository = async (input: {
projectId: string;
repositoryUrl: string;
branch: string;
}): Promise<{ commit: string }> => {
const handle = getProjectHandle(input.projectId);
// Idempotent: skip if repo dir already exists.
const exists = await handle.filesystem.exists(REPO_PATH).catch(() => false);
if (!exists) {
const result = await handle.process.execFile("git", [
"clone",
"--depth=1",
"--single-branch",
"--branch",
input.branch,
input.repositoryUrl,
REPO_PATH,
]);
requireExecSuccess(result, "git clone");
}
return readRepositoryHead(input.projectId);
};
/**
* List files in a directory relative to the repository root.
* Path traversal is prevented by safeRepoPath().
*/
export const listFiles = async (
projectId: string,
relativePath: string
): Promise<DirListEntry[]> => {
const handle = getProjectHandle(projectId);
const target = safeRepoPath(REPO_PATH, relativePath);
const entries = await handle.filesystem.readdirEntries(target);
return entries.map((e) => {
let kind: DirListEntry["kind"] = "file";
if (e.isDirectory) {
kind = "directory";
} else if (e.isSymbolicLink) {
kind = "symlink";
}
return { kind, name: e.name, size: e.size };
});
};
/**
* Verify repository readability: HEAD commit + root listing + read one file.
*/
export const describeRepository = async (
projectId: string
): Promise<RepoDescription> => {
const { commit } = await readRepositoryHead(projectId);
const entries = await listFiles(projectId, "");
return { commit, rootEntries: entries };
};
/**
* Read a file relative to the repository root. Path traversal prevented.
*/
export const readFile = async (
projectId: string,
relativePath: string
): Promise<{ content: string }> => {
const handle = getProjectHandle(projectId);
const target = safeRepoPath(REPO_PATH, relativePath);
const bytes = await handle.filesystem.readFile(target);
return { content: Buffer.from(bytes).toString("utf-8") };
};
/**
* Search repository files using grep. Returns path + snippet matches.
* Zero matches return an empty array rather than throwing.
*/
export const searchRepository = async (
projectId: string,
query: string
): Promise<{ matches: SearchResult[] }> => {
if (typeof query !== "string" || query.length === 0) {
throw new Error("searchRepository requires a non-empty query string");
}
// Validate exactly the args we execute.
const args = ["-rn", "--include=*", query, REPO_PATH];
validateSafeCommand("grep", args);
const handle = getProjectHandle(projectId);
const result = await handle.process.execFile("grep", args);
if (result.outcome !== "succeeded") {
if (result.exitCode === 1) {
return { matches: [] };
}
requireExecSuccess(result, "grep repository");
}
const lines = (result.stdout ?? "").split("\n").filter(Boolean).slice(0, 50);
return {
matches: lines.map((line) => {
const colonIdx = line.indexOf(":");
const secondColon = line.indexOf(":", colonIdx + 1);
return {
path: line.slice(0, colonIdx),
snippet: line.slice(secondColon + 1, secondColon + 1 + 200),
};
}),
};
};
/**
* Execute a validated safe command in the project VM. Only statically
* inspected commands / safe argument lists are permitted.
*/
export const execSafe = async (
projectId: string,
command: string,
args: readonly string[]
): Promise<{ stdout: string; stderr: string }> => {
validateSafeCommand(command, args);
const handle = getProjectHandle(projectId);
const result = await handle.process.execFile(command, [...args]);
requireExecSuccess(result, `execSafe ${command}`);
return { stderr: result.stderr ?? "", stdout: result.stdout ?? "" };
};
/**
* Scan root .env.example for variable names only (values are never stored
* or displayed). Non-blocking: returns empty array if absent.
*/
export const scanEnvExample = async (
projectId: string
): Promise<{ names: string[] }> => {
const handle = getProjectHandle(projectId);
const envPath = safeRepoPath(REPO_PATH, ".env.example");
const exists = await handle.filesystem.exists(envPath).catch(() => false);
if (!exists) {
return { names: [] };
}
const bytes = await handle.filesystem
.readFile(envPath)
.catch(() => new Uint8Array());
const content = Buffer.from(bytes).toString("utf-8");
const names: string[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const [rawName] = trimmed.split("=");
const name = rawName?.trim() ?? "";
if (name && /^[A-Z_][A-Z0-9_]*$/iu.test(name)) {
names.push(name);
}
}
return { names };
};
/**
* Write a generated file under .zopu/artifacts/. This is the only write path
* exposed — it is constrained to the .zopu output directory and never touches
* repository source files.
*/
export const writeArtifactFile = async (
projectId: string,
buildId: string,
filename: string,
content: string
): Promise<{ path: string }> => {
const handle = getProjectHandle(projectId);
const artifactPath = `/workspace/.zopu/artifacts/${buildId}/${filename}`;
// Ensure parent directory exists.
await handle.filesystem.mkdir(`/workspace/.zopu/artifacts/${buildId}`, {
recursive: true,
});
await handle.filesystem.writeFile(artifactPath, content);
return { path: artifactPath };
};
@@ -0,0 +1,103 @@
// Static HTML artifact renderer.
// Takes a typed ArtifactManifest and produces a safe, static HTML page.
// No user-supplied HTML is passed through — all content is escaped and
// rendered through the kit's component templates.
export interface ArtifactManifestSection {
title: string;
body: string;
items?: string[];
}
export interface ArtifactManifest {
kind: string;
title: string;
subtitle?: string;
sections: ArtifactManifestSection[];
generatedAt: string;
projectName?: string;
repositoryCommit?: string;
}
/** Escape HTML special characters to prevent injection. */
const escapeHtml = (text: string): string =>
text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
const renderSection = (section: ArtifactManifestSection): string => {
const items = section.items
? `<ul class="items">${section.items.map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>`
: "";
return ` <section>
<h2>${escapeHtml(section.title)}</h2>
<p>${escapeHtml(section.body)}</p>
${items}
</section>`;
};
/**
* Render a typed manifest to a polished, static HTML page.
* All content is escaped — the renderer supplies visual quality and safety.
*/
export const renderStaticHtml = (manifest: ArtifactManifest): string => {
const sections = manifest.sections.map(renderSection).join("\n");
const subtitle = manifest.subtitle
? `<p class="subtitle">${escapeHtml(manifest.subtitle)}</p>`
: "";
const meta: string[] = [];
if (manifest.projectName) {
meta.push(`<span>Project: ${escapeHtml(manifest.projectName)}</span>`);
}
if (manifest.repositoryCommit) {
meta.push(
`<span>Commit: <code>${escapeHtml(manifest.repositoryCommit)}</code></span>`
);
}
const metaHtml = meta.length
? `<div class="meta">${meta.join(" ")}</div>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(manifest.title)}</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #e6edf3; --muted: #8b949e; --accent: #2f81f7; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem 1rem; }
.container { max-width: 720px; margin: 0 auto; }
header { margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.5rem; }
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--accent); }
.subtitle { color: var(--muted); font-size: 1rem; }
.meta { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.75rem; font-size: 0.875rem; color: var(--muted); }
.meta code { background: var(--card); padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.8rem; }
section { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; }
section p { color: var(--text); margin-bottom: 0.75rem; }
.items { list-style: none; }
.items li { padding: 0.25rem 0 0.25rem 1.25rem; position: relative; color: var(--muted); }
.items li::before { content: "\\2022"; position: absolute; left: 0; color: var(--accent); }
footer { text-align: center; color: var(--muted); font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<div class="container">
<header>
<h1>${escapeHtml(manifest.title)}</h1>
${subtitle}
${metaHtml}
</header>
<main>
${sections}
</main>
<footer>Generated by Zopu at ${escapeHtml(manifest.generatedAt)}</footer>
</div>
</body>
</html>`;
};
@@ -0,0 +1,259 @@
import { env } from "@code/env/agent";
/* eslint-disable max-classes-per-file -- service client and its error type form one domain module. */
/**
* Service-authenticated Convex client for runtime → Convex callbacks.
*
* The agent's timeline/context tools call through this client to persist agent
* messages, summaries, artifacts, and timeline cards. It POSTs to the
* service-authenticated Convex Site HTTP actions exposed in
* `packages/backend/convex/agentHttp.ts`:
*
* POST /api/agents/events/message
* POST /api/agents/events/artifact
* POST /api/agents/storage/generate-upload-url
*
* These actions carry the FLUE_DB_TOKEN bearer so Convex authenticates the call
* as the intelligence runtime service (not a browser session) and enforce the
* agent→project→organization boundary server-side. The runtime never holds
* database credentials and never calls Convex mutations directly.
*/
/**
* Shared error type for non-OK responses from Convex service actions.
* The Convex httpActions return `{ error }` with a status of 400/401 on
* validation/auth failures.
*/
class ConvexServiceError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ConvexServiceError";
this.status = status;
}
}
/**
* Boundary context carried in every runtime → Convex callback. The Convex
* `resolveBoundary` helper validates these against the conversationAgents row.
*/
interface BoundaryInput {
agentId: string;
organizationId: string;
projectId: string;
}
export class ConvexServiceClient {
private readonly baseUrl: string;
private readonly token: string;
constructor() {
this.baseUrl = env.CONVEX_SITE_URL.replace(/\/$/u, "");
this.token = env.FLUE_DB_TOKEN;
}
/**
* POST an agent-authored message to the global timeline.
* Maps to the Convex `events:appendToolEvent` mutation via the
* `/api/agents/events/message` Site action. Idempotent by idempotencyKey.
*/
async postAgentMessage(input: {
organizationId: string;
projectId: string;
agentId: string;
text: string;
idempotencyKey: string;
correlationId: string;
type: string;
visibility: string;
replyToEventId?: string;
workId?: string;
threadId?: string;
}): Promise<{ eventId: string }> {
const result = await this.postJson("/api/agents/events/message", {
agentId: input.agentId,
correlationId: input.correlationId,
idempotencyKey: input.idempotencyKey,
organizationId: input.organizationId,
projectId: input.projectId,
replyToEventId: input.replyToEventId,
text: input.text,
threadId: input.threadId,
type: input.type,
visibility: input.visibility,
workId: input.workId,
});
return { eventId: result.eventId as string };
}
/**
* Upload rendered HTML (or other bytes) to Convex Storage and return the
* storageId. The flow is:
* 1. POST /api/agents/storage/generate-upload-url → one-time upload URL.
* 2. PUT the bytes as a Blob to the upload URL.
* 3. The Convex-generated storageId is the UUID in the upload URL path.
*
* Returns undefined if the upload could not be completed so the caller can
* still publish an inline-only artifact.
*/
async uploadHtml(
boundary: BoundaryInput,
html: string
): Promise<{ storageId: string } | undefined> {
// 1. Obtain a one-time upload URL.
const urlResult = await this.postJson(
"/api/agents/storage/generate-upload-url",
{
agentId: boundary.agentId,
organizationId: boundary.organizationId,
projectId: boundary.projectId,
}
);
const uploadUrl = urlResult.url as string | undefined;
if (!uploadUrl) {
return undefined;
}
// 2. PUT the bytes as a Blob to the upload URL.
const blob = new Blob([html], { type: "text/html" });
const uploadResponse = await fetch(uploadUrl, {
body: blob,
method: "POST",
});
if (!uploadResponse.ok) {
return undefined;
}
// 3. The Convex-generated storageId is in the upload URL's path
// (/upload/<uuid>) and is echoed back in the JSON body of the PUT.
const storageId = await uploadResponse
.json()
.then((body) => (body as { storageId?: string }).storageId)
.catch(() => {
/* malformed upload response body — fall through to URL fallback */
});
if (storageId) {
return { storageId };
}
// Fallback: extract the UUID from the upload URL path.
const match = /\/upload\/(?<id>[A-Za-z0-9_-]+)/u.exec(uploadUrl);
return match?.groups?.id ? { storageId: match.groups.id } : undefined;
}
/**
* Publish a typed artifact revision (with an optional Convex Storage id for
* rendered HTML) and emit the `artifact.published` timeline event. Maps to
* the Convex `artifacts:publishRevision` mutation + `events:appendToolEvent`
* via the `/api/agents/events/artifact` Site action.
*
* Note: the artifact revision requires a `createdByEventId` (the timeline
* event that produced it). The onboarding slice publishes the message event
* first, then uses its eventId here.
*/
async publishArtifact(input: {
organizationId: string;
projectId: string;
agentId: string;
createdByEventId: string;
kind: string;
logicalKey: string;
title: string;
summary: string;
status: string;
card: unknown;
content: unknown;
storageId?: string;
idempotencyKey: string;
correlationId: string;
workId?: string;
threadId?: string;
}): Promise<{ eventId: string; artifactId: string }> {
const result = await this.postJson("/api/agents/events/artifact", {
agentId: input.agentId,
card: input.card,
content: input.content,
correlationId: input.correlationId,
createdByEventId: input.createdByEventId,
idempotencyKey: input.idempotencyKey,
kind: input.kind,
logicalKey: input.logicalKey,
organizationId: input.organizationId,
projectId: input.projectId,
status: input.status,
storageId: input.storageId,
summary: input.summary,
threadId: input.threadId,
title: input.title,
workId: input.workId,
});
return {
artifactId: result.artifactId as string,
eventId: result.eventId as string,
};
}
/**
* Store a project context document (e.g. the onboarding summary).
* Published as an inline artifact revision of kind "summary" so it persists
* durably and is recoverable across agent restarts.
*/
upsertProjectSummary(input: {
organizationId: string;
projectId: string;
agentId: string;
kind: string;
content: string;
correlationId: string;
createdByEventId: string;
idempotencyKey: string;
}): Promise<{ eventId: string; artifactId: string }> {
return this.publishArtifact({
agentId: input.agentId,
card: { kind: input.kind },
content: { markdown: input.content },
correlationId: input.correlationId,
createdByEventId: input.createdByEventId,
idempotencyKey: input.idempotencyKey,
kind: "summary",
logicalKey: `project:${input.projectId}:summary`,
organizationId: input.organizationId,
projectId: input.projectId,
status: "ready",
summary: input.content.slice(0, 160),
title: "Project summary",
});
}
/**
* POST JSON to a Convex Site action with the service bearer token.
* Returns the parsed JSON body. Throws ConvexServiceError on non-2xx.
*/
private async postJson(
path: string,
body: Record<string, unknown>
): Promise<Record<string, unknown>> {
const response = await fetch(`${this.baseUrl}${path}`, {
body: JSON.stringify(body),
headers: {
authorization: `Bearer ${this.token}`,
"content-type": "application/json",
},
method: "POST",
});
if (!response.ok) {
const detail = await response
.json()
.then((b) => (b as { error?: string }).error)
.catch(() => {
/* non-JSON error body — fall through to status fallback */
});
throw new ConvexServiceError(
detail ?? `Convex action ${path} returned ${response.status}`,
response.status
);
}
return (await response.json()) as Record<string, unknown>;
}
}
@@ -0,0 +1,77 @@
"use agent";
import { useModel, useTool } from "@flue/runtime";
import { modelSpecifier } from "../model-config.ts";
import { projectContextUpdate } from "../tools/project-context.ts";
import {
sandboxDescribe,
sandboxListFiles,
sandboxReadFile,
sandboxSearch,
} from "../tools/sandbox.ts";
import {
timelinePostMessage,
timelinePublishArtifact,
} from "../tools/timeline.ts";
/**
* ProjectConversationAgent — one project-bound Flue 2.0 conversation agent.
*
* Identity is stable and structural: `conversation:<organizationId>:<projectId>:v1`.
* The agent instance id passed to dispatch() encodes these facts:
* conversation:<organizationId>:<projectId>
*
* On a `project.ready` signal it:
* 1. Posts one idempotent short acknowledgement.
* 2. Explores the repository via VM read-only tools.
* 3. Stores a concise summary via projectContextUpdate.
* 4. Renders a static setup artifact and publishes it to the timeline.
*
* The agent is read-oriented for the first slice — no autonomous code mutation.
*/
export const ProjectConversationAgent = () => {
useModel(modelSpecifier());
// Timeline tools — constrained write surface to Convex (not arbitrary mutations).
useTool(timelinePostMessage);
useTool(timelinePublishArtifact);
// Project context tools — store durable project knowledge.
useTool(projectContextUpdate);
// Sandbox tools — read-only repository access through the project VM.
useTool(sandboxDescribe);
useTool(sandboxListFiles);
useTool(sandboxReadFile);
useTool(sandboxSearch);
return `You are Zopu's project conversation agent — an effective coworker who already understands this project.
You are bound to exactly one project and its repository workspace VM. You have read-only access to the repository through your tools.
## Onboarding (project.ready)
When you receive a \`project.ready\` signal:
1. Acknowledge briefly (1-2 sentences) using timelinePostMessage with idempotencyKey "dispatch:<dispatchId>:message:acknowledge".
2. Use sandboxDescribe to understand the repository structure.
3. Use sandboxListFiles on the root to see the layout.
4. Read key files: README.md, package.json, or equivalent entry documentation.
5. Synthesize a concise summary and store it via projectContextUpdate (kind "summary").
6. Render a polished setup artifact via timelinePublishArtifact with idempotencyKey "dispatch:<dispatchId>:message:onboarding-artifact".
## Style
- Default to 1-3 short sentences per response.
- Be specific and evidence-backed — cite file paths.
- Do not claim code was changed, tested, or shipped when it was not.
- For writing requests: inspect, scope, and explain — do not falsely claim completion.
- Keep internal exploration invisible; post only meaningful results to the timeline.
## Constraints
- You may NOT mutate repository source files. Output only goes under .zopu/.
- You must NOT expose API keys, credentials, or secrets.
- You must NOT access paths outside the repository root.`;
};
// Durable identity — decoupled from the source-level function name so
// minification or renames cannot corrupt conversation storage.
ProjectConversationAgent.agentName = "project-conversation";
+253
View File
@@ -0,0 +1,253 @@
import { env } from "@code/env/agent";
import { dispatch } from "@flue/runtime";
import { createAgentRouter } from "@flue/runtime/routing";
import { Hono } from "hono";
import {
cloneRepository,
getOrCreateProjectVm,
describeRepository,
scanEnvExample,
} from "./adapters/agentos.ts";
import { ProjectConversationAgent } from "./agents/project-agent.ts";
import { registerModelProvider } from "./model-config.ts";
import { registry } from "./runner.ts";
// Register the Cheaptricks OpenAI-compatible provider before the server
// starts handling requests. The API key is resolved per-request inside the
// provider's auth resolver and is never exposed to model tools.
registerModelProvider();
const app = new Hono();
// --- Health (unauthenticated liveness) --------------------------------------
app.get("/health", (c) => c.json({ service: "zopu-agents", status: "ok" }));
// --- Auth -------------------------------------------------------------------
/** Validate the bearer token against FLUE_DB_TOKEN. */
const requireServiceAuth = (authHeader: string | undefined): boolean => {
if (!authHeader) {
return false;
}
const match = /^Bearer\s+(?<token>.+)$/u.exec(authHeader);
if (!match) {
return false;
}
return match.groups?.token === env.FLUE_DB_TOKEN;
};
// --- Internal project-setup endpoint ----------------------------------------
//
// Called by the Convex `projectSetup.runSetup` coordinator to perform the
// AgentOS VM get-or-create, shallow clone, readability verification, and root
// `.env.example` name-only scan. Returns exactly the coordinator contract.
//
// Convex request body (callSetupEndpoint in projectSetup.ts):
// { branch, projectId, repositoryUrl }
//
// Coordinator-expected response (SetupRuntimeResult):
// { runtimeId?, vmId?, workspacePath?, repositoryPath?,
// repositoryCommit?, environmentVariableNames? }
interface ProjectSetupRequest {
branch: string;
projectId: string;
repositoryUrl: string;
}
export interface ProjectSetupResult {
runtimeId?: string;
vmId?: string;
workspacePath?: string;
repositoryPath?: string;
repositoryCommit?: string;
environmentVariableNames?: string[];
}
/**
* POST /internal/project-setup
*
* Single-call setup coordinator target. Performs:
* 1. Get-or-create the project workspace VM (idempotent by projectId).
* 2. Shallow single-branch clone (idempotent — skips if repo dir exists).
* 3. Verify readability: HEAD commit + root listing + read one file.
* 4. Scan root `.env.example` for variable names only (values never stored).
*
* Never mutates repository source files. The returned object matches the
* Convex `SetupRuntimeResult` wire shape exactly.
*/
app.post("/internal/project-setup", async (c) => {
if (!requireServiceAuth(c.req.header("authorization"))) {
return c.json({ error: "unauthorized" }, 401);
}
let body: ProjectSetupRequest;
try {
body = (await c.req.json()) as ProjectSetupRequest;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
if (
typeof body.projectId !== "string" ||
body.projectId.length === 0 ||
typeof body.repositoryUrl !== "string" ||
body.repositoryUrl.length === 0 ||
typeof body.branch !== "string" ||
body.branch.length === 0
) {
return c.json({ error: "invalid project-setup request" }, 400);
}
try {
const vm = await getOrCreateProjectVm(body.projectId);
// Shallow clone (idempotent). Credentials are never exposed.
const { commit } = await cloneRepository({
branch: body.branch,
projectId: body.projectId,
repositoryUrl: body.repositoryUrl,
});
// Verify readability: HEAD commit + root listing (throws on failure).
await describeRepository(body.projectId);
// Scan root .env.example names-only.
const { names } = await scanEnvExample(body.projectId);
const result: ProjectSetupResult = {
environmentVariableNames: names,
repositoryCommit: commit,
repositoryPath: vm.repositoryPath,
runtimeId: vm.vmId,
vmId: vm.vmId,
workspacePath: vm.workspacePath,
};
return c.json(result, 200);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return c.json({ detail: message, error: "project-setup failed" }, 500);
}
});
// --- Private dispatch endpoint -----------------------------------------------
//
// Convex calls this with a bearer FLUE_DB_TOKEN to deliver an event to a
// project-bound conversation agent. The endpoint validates the envelope,
// calls Flue dispatch(), and returns 202 Accepted.
//
// Idempotency: the dispatchId is encoded in the signal's idempotencyKey so
// a retried Convex workflow step converges on the original submission.
export interface DispatchEnvelope {
dispatchId: string;
// The triggering event. Convex sends sourceEventId separately for
// correlation; sourceEvent carries the event type + payload the agent acts on.
sourceEvent: {
type: string;
payload?: unknown;
};
// The Convex events row id of the source event (e.g. the project.ready id).
// Used for reply correlation; optional because some dispatches may be
// ad-hoc.
sourceEventId?: string;
agentId: string;
organizationId: string;
projectId: string;
// Correlation id tying all setup/dispatch events together.
correlationId?: string;
workId?: string;
threadId?: string;
}
app.post("/internal/agents/:agentId/events", async (c) => {
// Bearer auth.
if (!requireServiceAuth(c.req.header("authorization"))) {
return c.json({ error: "unauthorized" }, 401);
}
const routeAgentId = c.req.param("agentId");
let body: DispatchEnvelope;
try {
body = (await c.req.json()) as DispatchEnvelope;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
// Validate the dispatch envelope.
if (
typeof body.dispatchId !== "string" ||
typeof body.agentId !== "string" ||
typeof body.organizationId !== "string" ||
typeof body.projectId !== "string" ||
!body.sourceEvent ||
typeof body.sourceEvent.type !== "string"
) {
return c.json({ error: "invalid dispatch envelope" }, 400);
}
// The route agentId must match the envelope agentId.
if (routeAgentId !== body.agentId) {
return c.json({ error: "agentId mismatch" }, 400);
}
// Build the stable conversation instance id. Must match
// conversationAgents.register: `conversation:<org>:<project>:v1`.
// The envelope agentId is already in that form.
const conversationId = body.agentId;
// Dispatch into Flue as a signal carrying the source event.
// The signal's attributes carry dispatch metadata so the agent's tools
// can resolve project/org/dispatch context during the turn.
try {
await dispatch(ProjectConversationAgent, {
id: conversationId,
idempotencyKey: `event:${body.dispatchId}:agent:${body.agentId}:handler:v1`,
message: {
attributes: {
agentId: body.agentId,
correlationId: body.correlationId ?? "",
dispatchId: body.dispatchId,
organizationId: body.organizationId,
projectId: body.projectId,
sourceEventId: body.sourceEventId ?? "",
threadId: body.threadId ?? "",
workId: body.workId ?? "",
},
body: JSON.stringify(body.sourceEvent),
kind: "signal",
type: body.sourceEvent.type,
},
});
return c.json(
{ accepted: true, conversationId, dispatchId: body.dispatchId },
202
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return c.json({ detail: message, error: "dispatch failed" }, 500);
}
});
// --- Public agent router (protected, optional) -------------------------------
// Mounted for direct conversation access. Protected by the same bearer auth.
// The dispatch-only path above is the primary entry for Convex → agent.
const agentSubApp = createAgentRouter(ProjectConversationAgent);
const protectedAgent = new Hono();
protectedAgent.use("*", async (c, next) => {
if (!requireServiceAuth(c.req.header("authorization"))) {
return c.json({ error: "unauthorized" }, 401);
}
return await next();
});
protectedAgent.route("/", agentSubApp as unknown as Hono);
app.route("/agents/project-conversation", protectedAgent);
// --- In-process RivetKit registry (serverless handler) -----------------------
//
// The AgentOS registry runs in serverless mode inside this same process.
// Mounted on a private path so only internal callers (and the Engine via
// configurePool.url) reach it. registry.handler drives the per-request
// serverless runtime; importing the registry object performs no start.
app.all("/internal/rivet/*", (c) => registry.handler(c.req.raw));
export default app;
+10
View File
@@ -0,0 +1,10 @@
import { sqlite } from "@flue/runtime/node";
// Flue 2.0 file-backed SQLite persistence adapter.
// Stores canonical conversation state and accepted submissions for the
// project-bound conversation agent. Application product truth remains Convex.
//
// Default path: /srv/zopu/data/flue/flue.db (overridable via FLUE_DB_PATH).
export default sqlite(
process.env.FLUE_DB_PATH ?? "/srv/zopu/data/flue/flue.db"
);
+16
View File
@@ -0,0 +1,16 @@
// @code/agents — Zopu Flue 2.0 + Hono + AgentOS intelligence runtime.
//
// The Flue 2.0 Node target generates dist/server.mjs via @flue/vite. That
// generated entry calls startFlueNodeServer(), which loads src/app.ts (the
// Hono route map) and src/db.ts (SQLite persistence) internally.
//
// This package's public exports are the reusable pieces: the conversation
// agent definition and the model configuration utilities. The server entry
// is dist/server.mjs (generated, not hand-authored).
export { ProjectConversationAgent } from "./agents/project-agent.ts";
export { registerModelProvider, modelSpecifier } from "./model-config.ts";
export type {
ArtifactManifest,
ArtifactManifestSection,
} from "./adapters/artifact-renderer.ts";
export type { DispatchEnvelope, ProjectSetupResult } from "./app.ts";
+58
View File
@@ -0,0 +1,58 @@
import { env } from "@code/env/agent";
import { createProvider } from "@earendil-works/pi-ai";
import type { Provider } from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { setProvider } from "@flue/runtime";
/**
* Register the Cheaptricks OpenAI-compatible provider with the Flue runtime.
*
* The provider is keyed by env.AGENT_MODEL_PROVIDER (default "cheaptricks"),
* exposing the model named by env.AGENT_MODEL_NAME (default "mimo-v2.5") over
* the OpenAI Completions API against env.AGENT_MODEL_BASE_URL.
*
* The API key is resolved at request time from env.AGENT_MODEL_API_KEY and is
* never exposed to model-facing tools — it lives only in the provider's auth
* resolver, not in agent instructions, tool inputs, or conversation state.
*/
export const registerModelProvider = (): Provider<"openai-completions"> => {
const provider = createProvider({
api: openAICompletionsApi(),
auth: {
apiKey: {
name: "Cheaptricks API key",
resolve: () =>
Promise.resolve({
auth: {
apiKey: env.AGENT_MODEL_API_KEY,
baseUrl: env.AGENT_MODEL_BASE_URL,
},
}),
},
},
baseUrl: env.AGENT_MODEL_BASE_URL,
id: env.AGENT_MODEL_PROVIDER,
models: [
{
api: "openai-completions",
baseUrl: env.AGENT_MODEL_BASE_URL,
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 },
id: env.AGENT_MODEL_NAME,
input: ["text"],
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
name: env.AGENT_MODEL_NAME,
provider: env.AGENT_MODEL_PROVIDER,
reasoning: false,
},
],
name: "Cheaptricks",
});
setProvider(provider);
return provider;
};
/** Full model specifier string for useModel(): provider-id/model-id. */
export const modelSpecifier = (): string =>
`${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`;
+65
View File
@@ -0,0 +1,65 @@
import git from "@agentos-software/git";
import { env } from "@code/env/agent";
import { agentOS, setup } from "@rivet-dev/agentos";
/**
* AgentOS registry — owns the durable project workspace actor.
*
* The actor is keyed structurally by [projectId] (project ids are globally
* unique), so each project resolves to one dedicated VM across the process's
* lifetime. The registry runs in serverless runtime mode and is served
* in-process by the Hono app (see app.ts), which mounts
* `registry.handler(c.req.raw)` at `/internal/rivet/*`. There is no
* standalone runner process and no `startAndWait` entry point.
*
* `configurePool.url` is the distinct endpoint the Engine invokes to reach
* this registry handler; it is NOT the Engine control-plane URL (that stays
* `endpoint: env.RIVET_ENDPOINT`). The actor still authenticates every
* connection via `onBeforeConnect` against RIVET_WORKSPACE_TOKEN, so only
* this deployment's adapter can drive the actor.
*
* Software: the actor bundles Git (@agentos-software/git) alongside the
* AgentOS default utilities. defaultSoftware alone is common utilities, not
* Git — without the explicit git entry, clone/rev-parse/grep would fail.
*
* Import-safe: this module exports a pure registry object and performs no
* top-level process start. Importing it (including `import type`) does not
* start a registry.
*/
// Project workspace actor — one VM per project, with Git + default utilities.
const projectWorkspace = agentOS<undefined, { token: string }>({
defaultSoftware: true,
onBeforeConnect: (_context, params) => {
if (params.token !== env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
}
},
// Git is required for clone, rev-parse, and read-only repository inspection.
// defaultSoftware provides sh/coreutils/common utilities only.
software: [git],
});
// Internal mount path shared by the Hono app route and the registry's
// serverless base path. Both MUST agree — the handler uses this base path to
// route actor/metadata requests, and the app must mount it at the same path.
export const RIVET_SERVERLESS_BASE_PATH = "/internal/rivet";
// Registry: the single actor type served by this process, in serverless mode.
// `serverless.basePath` matches the app route mount point; `configurePool.url`
// is the address the Engine calls back into this handler. Importing this
// module does not start the registry — the handler is driven per-request by
// the Hono app.
export const registry = setup({
configurePool: {
url: env.RIVET_SERVERLESS_ENDPOINT,
},
endpoint: env.RIVET_ENDPOINT,
logging: { level: "info" },
serverless: {
basePath: RIVET_SERVERLESS_BASE_PATH,
},
use: {
projectWorkspace,
},
});
@@ -0,0 +1,63 @@
import { defineTool, useDelivery } from "@flue/runtime";
import * as v from "valibot";
import { ConvexServiceClient } from "../adapters/convex-service-client.ts";
let client: ConvexServiceClient | null = null;
const getClient = (): ConvexServiceClient => {
if (!client) {
client = new ConvexServiceClient();
}
return client;
};
/**
* projectContextUpdate — store durable project knowledge (summary documents).
*
* Published as an inline artifact revision of kind "summary" via the Convex
* `/api/agents/events/artifact` Site action, so it persists durably in Convex
* and is recoverable across agent restarts. Requires a creation event (the
* current dispatch message) as the `createdByEventId`.
*/
export const projectContextUpdate = defineTool({
description:
"Store or update a project context document (e.g. the onboarding summary). The document persists in Convex and is recoverable across agent restarts.",
input: v.object({
content: v.pipe(
v.string(),
v.description("Markdown content of the document")
),
kind: v.pipe(v.string(), v.description("Document kind, e.g. 'summary'")),
}),
name: "projectContextUpdate",
run: async (ctx) => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside the tool's run callback per Flue tool contract.
const delivery = useDelivery();
if (delivery.kind !== "signal") {
throw new Error("projectContextUpdate requires a signal delivery");
}
const attrs = delivery.attributes ?? {};
const { organizationId, projectId, agentId, correlationId, sourceEventId } =
attrs;
if (
!organizationId ||
!projectId ||
!agentId ||
!correlationId ||
!sourceEventId
) {
throw new Error("Delivery context missing source event metadata");
}
await getClient().upsertProjectSummary({
agentId,
content: ctx.data.content,
correlationId,
createdByEventId: sourceEventId,
idempotencyKey: `project:${projectId}:summary:${ctx.data.kind}`,
kind: ctx.data.kind,
organizationId,
projectId,
});
return { output: { stored: true } };
},
});
+141
View File
@@ -0,0 +1,141 @@
import { defineTool, useDelivery } from "@flue/runtime";
import * as v from "valibot";
import {
describeRepository,
listFiles,
readFile,
searchRepository,
} from "../adapters/agentos.ts";
/**
* Sandbox tools — read-only repository access through the project VM.
* These are model-callable Flue tools that operate inside the agent's turn.
*
* Tool input schemas use Valibot (Flue 2.0's schema standard), not Typebox.
*
* The project context (projectId) is extracted from the delivery signal's
* attributes, which carry the dispatch metadata.
*/
/** Extract dispatch metadata from the current delivery signal. */
const getDispatchMeta = (): {
projectId: string;
organizationId: string;
agentId: string;
dispatchId: string;
correlationId: string;
} => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside a tool helper per Flue tool contract.
const delivery = useDelivery();
if (delivery.kind !== "signal") {
throw new Error("Sandbox tools require a signal delivery");
}
const attrs = delivery.attributes ?? {};
const { projectId } = attrs;
const { organizationId } = attrs;
const { agentId } = attrs;
const { dispatchId } = attrs;
const { correlationId } = attrs;
if (
!projectId ||
!organizationId ||
!agentId ||
!dispatchId ||
!correlationId
) {
throw new Error("No projectId in delivery context");
}
return { agentId, correlationId, dispatchId, organizationId, projectId };
};
/** sandboxDescribe: report workspace/repo paths and current HEAD commit. */
export const sandboxDescribe = defineTool({
description:
"Describe the project repository workspace: paths and current HEAD commit. Call this first when exploring a repository.",
input: v.object({}),
name: "sandboxDescribe",
run: async () => {
const { projectId } = getDispatchMeta();
const desc = await describeRepository(projectId);
const rootEntries = desc.rootEntries.map((e) => ({
kind: e.kind,
name: e.name,
size: e.size,
}));
return {
output: {
commit: desc.commit,
repositoryPath: "/workspace/repo",
rootEntries,
},
};
},
});
/** sandboxListFiles: list directory entries relative to repo root. */
export const sandboxListFiles = defineTool({
description:
"List files and directories at a path relative to the repository root.",
input: v.object({
path: v.pipe(
v.string(),
v.description(
"Directory path relative to repository root (use '' for root)"
)
),
}),
name: "sandboxListFiles",
run: async (ctx) => {
const { projectId } = getDispatchMeta();
const result = await listFiles(projectId, ctx.data.path);
const entries = result.map((e) => ({
kind: e.kind,
name: e.name,
size: e.size,
}));
return { output: { entries } };
},
});
/** sandboxReadFile: read a file's content relative to repo root. */
export const sandboxReadFile = defineTool({
description:
"Read the text content of a file at a path relative to the repository root.",
input: v.object({
path: v.pipe(
v.string(),
v.description("File path relative to repository root")
),
}),
name: "sandboxReadFile",
run: async (ctx) => {
const { projectId } = getDispatchMeta();
const result = await readFile(projectId, ctx.data.path);
// Truncate very large files to keep context bounded.
const content =
result.content.length > 50_000
? `${result.content.slice(0, 50_000)}\n...[truncated]`
: result.content;
return { output: { content } };
},
});
/** sandboxSearch: search repository for a text query. */
export const sandboxSearch = defineTool({
description:
"Search the repository for a text query. Returns matching file paths and snippets.",
input: v.object({
query: v.pipe(v.string(), v.description("Search query (text or pattern)")),
}),
name: "sandboxSearch",
run: async (ctx) => {
const { projectId } = getDispatchMeta();
const result = await searchRepository(projectId, ctx.data.query);
const matches = result.matches.map((m) => ({
path: m.path,
snippet: m.snippet,
}));
return { output: { matches } };
},
});
+185
View File
@@ -0,0 +1,185 @@
import { defineTool, useDelivery } from "@flue/runtime";
import * as v from "valibot";
import { renderStaticHtml } from "../adapters/artifact-renderer.ts";
import { ConvexServiceClient } from "../adapters/convex-service-client.ts";
let client: ConvexServiceClient | null = null;
const getClient = (): ConvexServiceClient => {
if (!client) {
client = new ConvexServiceClient();
}
return client;
};
/** Extract dispatch/project context from the current delivery signal. */
const getDispatchContext = (): {
dispatchId: string;
correlationId: string;
organizationId: string;
projectId: string;
agentId: string;
sourceEventId: string;
} => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside a tool helper per Flue tool contract.
const delivery = useDelivery();
if (delivery.kind !== "signal") {
throw new Error("Timeline tools require a signal delivery");
}
const attrs = delivery.attributes ?? {};
const { dispatchId } = attrs;
const { correlationId } = attrs;
const { organizationId } = attrs;
const { projectId } = attrs;
const { agentId } = attrs;
const { sourceEventId } = attrs;
if (
!dispatchId ||
!correlationId ||
!organizationId ||
!projectId ||
!agentId ||
!sourceEventId
) {
throw new Error("Delivery context missing source event metadata");
}
return {
agentId,
correlationId,
dispatchId,
organizationId,
projectId,
sourceEventId,
};
};
/**
* timelinePostMessage — post an agent-authored message to the global timeline.
* The message is idempotent by the provided idempotencyKey; a retry with the
* same key converges on the original event.
*
* Posts through the Convex `/api/agents/events/message` Site action, which
* enforces the agent→project→organization boundary and exact-once semantics.
*/
export const timelinePostMessage = defineTool({
description:
"Post a short message to the project timeline. Use for acknowledgements and brief responses (1-3 sentences). Provide a unique idempotencyKey.",
input: v.object({
idempotencyKey: v.pipe(
v.string(),
v.description("Stable idempotency key for this message")
),
importance: v.optional(
v.pipe(
v.union([v.literal("normal"), v.literal("high")]),
v.description("Message importance level (default: normal)")
)
),
text: v.pipe(
v.string(),
v.description("The message text to post (1-3 sentences)")
),
}),
name: "timelinePostMessage",
run: async (ctx) => {
const { correlationId, organizationId, projectId, agentId, sourceEventId } =
getDispatchContext();
const result = await getClient().postAgentMessage({
agentId,
correlationId,
idempotencyKey: ctx.data.idempotencyKey,
organizationId,
projectId,
replyToEventId: sourceEventId,
text: ctx.data.text,
type: "agent.message",
visibility: "timeline",
});
return { output: { eventId: result.eventId } };
},
});
/**
* timelinePublishArtifact — render and publish a static setup artifact.
*
* Two-step flow matching the Convex artifact contract:
* 1. Post an acknowledgement/creation message (the `createdByEventId`) so the
* artifact revision is tied to a timeline event.
* 2. Render safe static HTML, upload it to Convex Storage, and publish the
* artifact revision referencing the uploaded storageId.
*
* The artifact is idempotent by logicalKey + version (server-side) and the
* event is exact-once by idempotencyKey.
*/
export const timelinePublishArtifact = defineTool({
description:
"Render a project setup artifact from structured sections and publish it to the timeline. Provide title, subtitle, and content sections.",
input: v.object({
idempotencyKey: v.pipe(
v.string(),
v.description("Stable idempotency key for this artifact")
),
sections: v.array(
v.object({
body: v.string(),
items: v.optional(v.array(v.string())),
title: v.string(),
})
),
subtitle: v.optional(
v.pipe(v.string(), v.description("Artifact subtitle"))
),
title: v.pipe(v.string(), v.description("Artifact title")),
}),
name: "timelinePublishArtifact",
run: async (ctx) => {
const { correlationId, organizationId, projectId, agentId } =
getDispatchContext();
// Step 1: create the timeline event that the artifact revision is tied to.
const createdBy = await getClient().postAgentMessage({
agentId,
correlationId,
idempotencyKey: `${ctx.data.idempotencyKey}:create`,
organizationId,
projectId,
text: `Published artifact: ${ctx.data.title}`,
type: "artifact.created",
visibility: "compact",
});
// Step 2: render + upload + publish the artifact revision.
const manifest = {
generatedAt: new Date().toISOString(),
kind: "project_setup",
sections: ctx.data.sections,
subtitle: ctx.data.subtitle,
title: ctx.data.title,
};
const html = renderStaticHtml(manifest);
const boundary = { agentId, organizationId, projectId };
const uploaded = await getClient().uploadHtml(boundary, html);
const result = await getClient().publishArtifact({
agentId,
card: {
sections: ctx.data.sections,
subtitle: ctx.data.subtitle,
},
content: { manifest },
correlationId,
createdByEventId: createdBy.eventId,
idempotencyKey: ctx.data.idempotencyKey,
kind: "project_setup",
logicalKey: `project:${projectId}:setup`,
organizationId,
projectId,
status: "ready",
storageId: uploaded?.storageId,
summary: ctx.data.subtitle ?? ctx.data.title,
title: ctx.data.title,
});
return {
output: { artifactId: result.artifactId, eventId: result.eventId },
};
},
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "*.d.ts"],
"exclude": ["node_modules", "dist"]
}
+22
View File
@@ -0,0 +1,22 @@
import { flue } from "@flue/vite";
import { defineConfig } from "vite";
// Flue 2.0 Node target. @flue/vite auto-discovers flue.config.ts, scans
// 'use agent' modules, and builds dist/server.mjs. The built server listens
// on 127.0.0.1:3000 (Caddy terminates TLS upstream at agents.zopu.puter.wtf).
export default defineConfig({
plugins: [
...flue({
providers: [],
target: "node",
}),
],
preview: {
host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000),
},
server: {
host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT ?? 3000),
},
});