diff --git a/packages/core/src/agent/profile/types.ts b/packages/core/src/agent/profile/types.ts index 228a0788..631eb4b1 100644 --- a/packages/core/src/agent/profile/types.ts +++ b/packages/core/src/agent/profile/types.ts @@ -31,6 +31,8 @@ export interface ProfileConfig { reasoningMode?: "off" | "on" | "stream" | undefined; /** Exec approval configuration (security level, ask mode, allowlist) */ execApproval?: ExecApprovalConfig | undefined; + /** Custom workspace directory path (overrides default) */ + workspaceDir?: string | undefined; /** Heartbeat configuration */ heartbeat?: { /** Global heartbeat enable switch */ diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 105eee43..74d8edb5 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -50,6 +50,7 @@ import { sanitizeToolUseResultPairing, } from "./session/session-transcript-repair.js"; import { isContextOverflowError } from "./errors.js"; +import { resolveWorkspaceDir, ensureWorkspaceDir } from "./workspace.js"; // ============================================================ // Error classification for auth profile rotation @@ -121,6 +122,9 @@ export class Agent { private readonly pinnedProfile: boolean; private readonly explicitApiKey: boolean; + /** Resolved workspace directory */ + readonly workspaceDir: string; + /** Current session ID */ readonly sessionId: string; @@ -318,13 +322,22 @@ export class Agent { this.originalToolsConfig = options.tools; } + // Resolve workspace directory + const profileConfig = this.profile?.getProfile()?.config; + this.workspaceDir = resolveWorkspaceDir({ + profileId: options.profileId, + configWorkspaceDir: profileConfig?.workspaceDir, + }); + ensureWorkspaceDir(this.workspaceDir); + const effectiveCwd = options.cwd ?? this.workspaceDir; + // Merge Profile tools config with options.tools (options takes precedence) const profileToolsConfig = this.profile?.getToolsConfig(); const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); const profileDir = this.profile?.getProfileDir(); this.toolsOptions = mergedToolsConfig - ? { ...options, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } - : { ...options, profileDir, provider: this.resolvedProvider }; + ? { ...options, cwd: effectiveCwd, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider } + : { ...options, cwd: effectiveCwd, profileDir, provider: this.resolvedProvider }; const tools = resolveTools(this.toolsOptions); if (this.debug) { @@ -1155,6 +1168,7 @@ export class Agent { agentName: this.profile?.getName(), provider: this.resolvedProvider, model: this.agent.state.model?.id, + cwd: this.toolsOptions.cwd, }); return buildStructuredSystemPrompt({ @@ -1168,6 +1182,7 @@ export class Agent { config: profile.config, }, profileDir: this.profile!.getProfileDir(), + workspaceDir: this.workspaceDir, tools: toolNames, skillsPrompt, runtime, diff --git a/packages/core/src/agent/system-prompt/builder.ts b/packages/core/src/agent/system-prompt/builder.ts index 78829ebf..82d01860 100644 --- a/packages/core/src/agent/system-prompt/builder.ts +++ b/packages/core/src/agent/system-prompt/builder.ts @@ -46,6 +46,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { mode, profile, profileDir, + workspaceDir, tools, skillsPrompt, runtime, @@ -58,7 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { const candidates: Array<{ name: string; lines: string[] }> = [ { name: "identity", lines: buildIdentitySection(profile, mode) }, { name: "user", lines: buildUserSection(profile, mode) }, - { name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) }, + { name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir, workspaceDir) }, { name: "memory", lines: buildMemoryFileSection(profile, mode) }, { name: "heartbeat", lines: buildHeartbeatSection(profile, mode) }, { name: "safety", lines: buildSafetySection(includeSafety) }, diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index bc0bc6ed..6826aa7e 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -89,11 +89,23 @@ export function buildWorkspaceSection( profile: ProfileContent | undefined, mode: SystemPromptMode, profileDir?: string, + workspaceDir?: string, ): string[] { if (mode !== "full") return []; const lines: string[] = []; + // Working directory info + if (workspaceDir) { + lines.push( + "## Working Directory", + "", + `Your working directory is: \`${workspaceDir}\``, + "Use this as the default location for file operations unless the user specifies a different path.", + "", + ); + } + // Add profile directory context first if (profileDir) { lines.push( diff --git a/packages/core/src/agent/system-prompt/types.ts b/packages/core/src/agent/system-prompt/types.ts index 9f75788e..cf1cc1ba 100644 --- a/packages/core/src/agent/system-prompt/types.ts +++ b/packages/core/src/agent/system-prompt/types.ts @@ -75,6 +75,8 @@ export interface SystemPromptOptions { runtime?: RuntimeInfo | undefined; /** Subagent context (for minimal/none modes) */ subagent?: SubagentContext | undefined; + /** Workspace directory path (for agent working directory info) */ + workspaceDir?: string | undefined; /** Extra system prompt to append */ extraSystemPrompt?: string | undefined; /** Whether to include the safety constitution (default: true) */ diff --git a/packages/core/src/agent/workspace.test.ts b/packages/core/src/agent/workspace.test.ts new file mode 100644 index 00000000..451a92d8 --- /dev/null +++ b/packages/core/src/agent/workspace.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveWorkspaceDir, ensureWorkspaceDir } from "./workspace.js"; +import { DATA_DIR, DEFAULT_WORKSPACE_DIR } from "@multica/utils"; + +describe("resolveWorkspaceDir", () => { + const originalEnv = process.env.MULTICA_WORKSPACE_DIR; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.MULTICA_WORKSPACE_DIR; + } else { + process.env.MULTICA_WORKSPACE_DIR = originalEnv; + } + }); + + it("returns ~/.super-multica/workspace/default for default profile", () => { + delete process.env.MULTICA_WORKSPACE_DIR; + expect(resolveWorkspaceDir()).toBe(path.join(DEFAULT_WORKSPACE_DIR, "default")); + expect(resolveWorkspaceDir({ profileId: "default" })).toBe(path.join(DEFAULT_WORKSPACE_DIR, "default")); + }); + + it("returns ~/.super-multica/workspace/{id} for named profile", () => { + delete process.env.MULTICA_WORKSPACE_DIR; + const result = resolveWorkspaceDir({ profileId: "research" }); + expect(result).toBe(path.join(DEFAULT_WORKSPACE_DIR, "research")); + }); + + it("prioritizes MULTICA_WORKSPACE_DIR env var", () => { + process.env.MULTICA_WORKSPACE_DIR = "/tmp/custom-ws"; + expect(resolveWorkspaceDir({ profileId: "default" })).toBe("/tmp/custom-ws"); + }); + + it("prioritizes config workspaceDir over profile default", () => { + delete process.env.MULTICA_WORKSPACE_DIR; + const result = resolveWorkspaceDir({ + profileId: "default", + configWorkspaceDir: "/tmp/config-ws", + }); + expect(result).toBe("/tmp/config-ws"); + }); + + it("env var takes precedence over config", () => { + process.env.MULTICA_WORKSPACE_DIR = "/tmp/env-ws"; + const result = resolveWorkspaceDir({ + profileId: "default", + configWorkspaceDir: "/tmp/config-ws", + }); + expect(result).toBe("/tmp/env-ws"); + }); + + it("expands ~ in env var", () => { + process.env.MULTICA_WORKSPACE_DIR = "~/my-workspace"; + const result = resolveWorkspaceDir(); + expect(result).toBe(path.resolve(path.join(os.homedir(), "my-workspace"))); + }); + + it("expands ~ in config workspaceDir", () => { + delete process.env.MULTICA_WORKSPACE_DIR; + const result = resolveWorkspaceDir({ configWorkspaceDir: "~/my-ws" }); + expect(result).toBe(path.resolve(path.join(os.homedir(), "my-ws"))); + }); +}); + +describe("ensureWorkspaceDir", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "multica-ws-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("creates directory and README.md", () => { + const wsDir = path.join(tmpDir, "workspace"); + ensureWorkspaceDir(wsDir); + + expect(fs.existsSync(wsDir)).toBe(true); + const readmePath = path.join(wsDir, "README.md"); + expect(fs.existsSync(readmePath)).toBe(true); + const content = fs.readFileSync(readmePath, "utf-8"); + expect(content).toContain("Multica Workspace"); + }); + + it("does not overwrite existing README.md", () => { + const wsDir = path.join(tmpDir, "workspace"); + fs.mkdirSync(wsDir, { recursive: true }); + const readmePath = path.join(wsDir, "README.md"); + fs.writeFileSync(readmePath, "custom content"); + + ensureWorkspaceDir(wsDir); + + const content = fs.readFileSync(readmePath, "utf-8"); + expect(content).toBe("custom content"); + }); + + it("succeeds when directory already exists", () => { + const wsDir = path.join(tmpDir, "workspace"); + fs.mkdirSync(wsDir, { recursive: true }); + + expect(() => ensureWorkspaceDir(wsDir)).not.toThrow(); + }); +}); diff --git a/packages/core/src/agent/workspace.ts b/packages/core/src/agent/workspace.ts new file mode 100644 index 00000000..c30eb271 --- /dev/null +++ b/packages/core/src/agent/workspace.ts @@ -0,0 +1,45 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DATA_DIR, DEFAULT_WORKSPACE_DIR } from "@multica/utils"; + +/** + * Resolve the workspace directory for a given profile. + * Priority: env var > config > profile-based default + */ +export function resolveWorkspaceDir(options?: { + profileId?: string; + configWorkspaceDir?: string; +}): string { + // 1. Env var override + const envDir = process.env.MULTICA_WORKSPACE_DIR?.trim(); + if (envDir) return path.resolve(envDir.replace(/^~/, os.homedir())); + + // 2. Config override (from profile config.json) + if (options?.configWorkspaceDir?.trim()) { + return path.resolve(options.configWorkspaceDir.replace(/^~/, os.homedir())); + } + + // 3. Profile-based default: ~/.super-multica/workspace/{profileId} + const profileId = options?.profileId ?? "default"; + return path.join(DEFAULT_WORKSPACE_DIR, profileId); +} + +/** + * Ensure workspace directory exists. Creates README.md on first creation. + */ +export function ensureWorkspaceDir(dir: string): void { + mkdirSync(dir, { recursive: true }); + const readmePath = path.join(dir, "README.md"); + try { + writeFileSync(readmePath, README_CONTENT, { encoding: "utf-8", flag: "wx" }); + } catch (err) { + if ((err as { code?: string }).code !== "EEXIST") throw err; + } +} + +const README_CONTENT = `# Multica Workspace + +This directory is the default workspace for your Multica agent. +Files created by the agent will be saved here unless you specify a different directory. +`; diff --git a/packages/utils/src/paths.ts b/packages/utils/src/paths.ts index 3c047312..da5ebece 100644 --- a/packages/utils/src/paths.ts +++ b/packages/utils/src/paths.ts @@ -6,3 +6,6 @@ export const DATA_DIR = join(homedir(), ".super-multica"); /** Cache directory for downloaded media files */ export const MEDIA_CACHE_DIR = join(DATA_DIR, "cache", "media"); + +/** Default workspace directory for the default profile */ +export const DEFAULT_WORKSPACE_DIR = join(DATA_DIR, "workspace");