feat: bootstrap agent workspace and AGENTS.md

This commit is contained in:
Peter Steinberger
2025-12-14 03:14:51 +00:00
parent 41da61dd6a
commit 073285409b
13 changed files with 351 additions and 31 deletions

View File

@@ -0,0 +1,32 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAgentWorkspace } from "./workspace.js";
describe("ensureAgentWorkspace", () => {
it("creates directory and AGENTS.md when missing", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-"));
const nested = path.join(dir, "nested");
const result = await ensureAgentWorkspace({
dir: nested,
ensureAgentsFile: true,
});
expect(result.dir).toBe(path.resolve(nested));
expect(result.agentsPath).toBe(
path.join(path.resolve(nested), "AGENTS.md"),
);
expect(result.agentsPath).toBeDefined();
if (!result.agentsPath) throw new Error("agentsPath missing");
const content = await fs.readFile(result.agentsPath, "utf-8");
expect(content).toContain("# AGENTS.md");
});
it("does not overwrite existing AGENTS.md", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-"));
const agentsPath = path.join(dir, "AGENTS.md");
await fs.writeFile(agentsPath, "custom", "utf-8");
await ensureAgentWorkspace({ dir, ensureAgentsFile: true });
expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom");
});
});

46
src/agents/workspace.ts Normal file
View File

@@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(CONFIG_DIR, "workspace");
export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";
const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md — Clawdis Workspace
This folder is the assistants working directory.
## Safety defaults
- Dont exfiltrate secrets or private data.
- Dont run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## How to use this
- Put project notes, scratch files, and “memory” here.
- Customize this file with additional instructions for your assistant.
`;
export async function ensureAgentWorkspace(params?: {
dir?: string;
ensureAgentsFile?: boolean;
}): Promise<{ dir: string; agentsPath?: string }> {
const rawDir = params?.dir?.trim()
? params.dir.trim()
: DEFAULT_AGENT_WORKSPACE_DIR;
const dir = resolveUserPath(rawDir);
await fs.mkdir(dir, { recursive: true });
if (!params?.ensureAgentsFile) return { dir };
const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME);
try {
await fs.writeFile(agentsPath, DEFAULT_AGENTS_TEMPLATE, {
encoding: "utf-8",
flag: "wx",
});
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "EEXIST") throw err;
}
return { dir, agentsPath };
}

View File

@@ -362,6 +362,15 @@ export async function runCommandReply(
typeof reply.cwd === "string" && reply.cwd.trim()
? resolveUserPath(reply.cwd)
: undefined;
if (resolvedCwd) {
try {
await fs.mkdir(resolvedCwd, { recursive: true });
} catch (err) {
throw new Error(
`Failed to create reply.cwd directory (${resolvedCwd}): ${String(err)}`,
);
}
}
if (!reply.command?.length) {
throw new Error("reply.command is required for mode=command");

View File

@@ -3,6 +3,10 @@ import crypto from "node:crypto";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import { resolveBundledPiBinary } from "../agents/pi-path.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
@@ -18,6 +22,7 @@ import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerClawdisRestart } from "../infra/restart.js";
import { drainSystemEvents } from "../infra/system-events.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
import { runCommandReply } from "./command-reply.js";
@@ -44,6 +49,8 @@ const SYSTEM_MARK = "⚙️";
type ReplyConfig = NonNullable<ClawdisConfig["inbound"]>["reply"];
type ResolvedReplyConfig = NonNullable<ReplyConfig>;
export function extractThinkDirective(body?: string): {
cleaned: string;
thinkLevel?: ThinkLevel;
@@ -136,7 +143,7 @@ function stripMentions(
return result.replace(/\s+/g, " ").trim();
}
function makeDefaultPiReply(): ReplyConfig {
function makeDefaultPiReply(): ResolvedReplyConfig {
const piBin = resolveBundledPiBinary() ?? "pi";
const defaultContext =
lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS;
@@ -165,8 +172,21 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig();
const reply: ReplyConfig = cfg.inbound?.reply ?? makeDefaultPiReply();
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
const workspaceDir = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const configuredReply = cfg.inbound?.reply as ResolvedReplyConfig | undefined;
const reply: ResolvedReplyConfig = configuredReply
? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir }
: { ...makeDefaultPiReply(), cwd: workspaceDir };
// Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it.
if (reply.mode === "command" && typeof reply.cwd === "string") {
const resolvedWorkspace = resolveUserPath(workspaceDir);
const resolvedCwd = resolveUserPath(reply.cwd);
if (resolvedCwd === resolvedWorkspace) {
await ensureAgentWorkspace({ dir: workspaceDir, ensureAgentsFile: true });
}
}
const timeoutSeconds = Math.max(reply.timeoutSeconds ?? 600, 1);
const timeoutMs = timeoutSeconds * 1000;
let started = false;
const triggerTyping = async () => {

View File

@@ -89,6 +89,8 @@ export type ClawdisConfig = {
browser?: BrowserConfig;
inbound?: {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "")
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC)
@@ -228,6 +230,7 @@ const ClawdisSchema = z.object({
inbound: z
.object({
allowFrom: z.array(z.string()).optional(),
workspace: z.string().optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),