diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index b46f11578..1a52fc501 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections: - **Current Date & Time**: user-local time, timezone, and time format. - **Reply Tags**: optional reply tag syntax for supported providers. - **Heartbeats**: heartbeat prompt and ack behavior. -- **Runtime**: host, OS, node, model, thinking level (one line). +- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Reasoning**: current visibility level + /reasoning toggle hint. ## Prompt modes diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 6cdc39394..1665fdcc8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1266,6 +1266,18 @@ Default: `~/clawd`. If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. +### `agents.defaults.repoRoot` + +Optional repository root to show in the system prompt’s Runtime line. If unset, Clawdbot +tries to detect a `.git` directory by walking upward from the workspace (and current +working directory). The path must exist to be used. + +```json5 +{ + agents: { defaults: { repoRoot: "~/Projects/clawdbot" } } +} +``` + ### `agents.defaults.skipBootstrap` Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index c1d96ea71..26ee43495 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -183,6 +183,8 @@ export function buildSystemPrompt(params: { const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: params.agentId, + workspaceDir: params.workspaceDir, + cwd: process.cwd(), runtime: { host: "clawdbot", os: `${os.type()} ${os.release()}`, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 19450226c..f16a71759 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -279,6 +279,8 @@ export async function runEmbeddedAttempt( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: sessionAgentId, + workspaceDir: effectiveWorkspace, + cwd: process.cwd(), runtime: { host: machineName, os: `${os.type()} ${os.release()}`, diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts new file mode 100644 index 000000000..fd108a3c7 --- /dev/null +++ b/src/agents/system-prompt-params.test.ts @@ -0,0 +1,106 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { buildSystemPromptParams } from "./system-prompt-params.js"; + +async function makeTempDir(label: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`)); +} + +async function makeRepoRoot(root: string): Promise { + await fs.mkdir(path.join(root, ".git"), { recursive: true }); +} + +function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) { + return buildSystemPromptParams({ + config: params.config, + workspaceDir: params.workspaceDir, + cwd: params.cwd, + runtime: { + host: "host", + os: "os", + arch: "arch", + node: "node", + model: "model", + }, + }); +} + +describe("buildSystemPromptParams repo root", () => { + it("detects repo root from workspaceDir", async () => { + const temp = await makeTempDir("workspace"); + const repoRoot = path.join(temp, "repo"); + const workspaceDir = path.join(repoRoot, "nested", "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await makeRepoRoot(repoRoot); + + const { runtimeInfo } = buildParams({ workspaceDir }); + + expect(runtimeInfo.repoRoot).toBe(repoRoot); + }); + + it("falls back to cwd when workspaceDir has no repo", async () => { + const temp = await makeTempDir("cwd"); + const repoRoot = path.join(temp, "repo"); + const workspaceDir = path.join(temp, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await makeRepoRoot(repoRoot); + + const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot }); + + expect(runtimeInfo.repoRoot).toBe(repoRoot); + }); + + it("uses configured repoRoot when valid", async () => { + const temp = await makeTempDir("config"); + const repoRoot = path.join(temp, "config-root"); + const workspaceDir = path.join(temp, "workspace"); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + await makeRepoRoot(workspaceDir); + + const config: ClawdbotConfig = { + agents: { + defaults: { + repoRoot, + }, + }, + }; + + const { runtimeInfo } = buildParams({ config, workspaceDir }); + + expect(runtimeInfo.repoRoot).toBe(repoRoot); + }); + + it("ignores invalid repoRoot config and auto-detects", async () => { + const temp = await makeTempDir("invalid"); + const repoRoot = path.join(temp, "repo"); + const workspaceDir = path.join(repoRoot, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await makeRepoRoot(repoRoot); + + const config: ClawdbotConfig = { + agents: { + defaults: { + repoRoot: path.join(temp, "missing"), + }, + }, + }; + + const { runtimeInfo } = buildParams({ config, workspaceDir }); + + expect(runtimeInfo.repoRoot).toBe(repoRoot); + }); + + it("returns undefined when no repo is found", async () => { + const workspaceDir = await makeTempDir("norepo"); + + const { runtimeInfo } = buildParams({ workspaceDir }); + + expect(runtimeInfo.repoRoot).toBeUndefined(); + }); +}); diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 21a97831a..9de8f481a 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; + import type { ClawdbotConfig } from "../config/config.js"; import { formatUserTime, @@ -18,6 +21,7 @@ export type RuntimeInfoInput = { capabilities?: string[]; /** Supported message actions for the current channel (e.g., react, edit, unsend) */ channelActions?: string[]; + repoRoot?: string; }; export type SystemPromptRuntimeParams = { @@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: { config?: ClawdbotConfig; agentId?: string; runtime: Omit; + workspaceDir?: string; + cwd?: string; }): SystemPromptRuntimeParams { + const repoRoot = resolveRepoRoot({ + config: params.config, + workspaceDir: params.workspaceDir, + cwd: params.cwd, + }); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); @@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: { runtimeInfo: { agentId: params.agentId, ...params.runtime, + repoRoot, }, userTimezone, userTime, userTimeFormat, }; } + +function resolveRepoRoot(params: { + config?: ClawdbotConfig; + workspaceDir?: string; + cwd?: string; +}): string | undefined { + const configured = params.config?.agents?.defaults?.repoRoot?.trim(); + if (configured) { + try { + const resolved = path.resolve(configured); + const stat = fs.statSync(resolved); + if (stat.isDirectory()) return resolved; + } catch { + // ignore invalid config path + } + } + const candidates = [params.workspaceDir, params.cwd] + .map((value) => value?.trim()) + .filter(Boolean) as string[]; + const seen = new Set(); + for (const candidate of candidates) { + const resolved = path.resolve(candidate); + if (seen.has(resolved)) continue; + seen.add(resolved); + const root = findGitRoot(resolved); + if (root) return root; + } + return undefined; +} + +function findGitRoot(startDir: string): string | null { + let current = path.resolve(startDir); + for (let i = 0; i < 12; i += 1) { + const gitPath = path.join(current, ".git"); + try { + const stat = fs.statSync(gitPath); + if (stat.isDirectory() || stat.isFile()) return current; + } catch { + // ignore missing .git at this level + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fce27677a..2f0e936e4 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => { { agentId: "work", host: "host", + repoRoot: "/repo", os: "macOS", arch: "arm64", node: "v20", @@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => { expect(line).toContain("agent=work"); expect(line).toContain("host=host"); + expect(line).toContain("repo=/repo"); expect(line).toContain("os=macOS (arm64)"); expect(line).toContain("node=v20"); expect(line).toContain("model=anthropic/claude"); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dbf30414f..772f154e4 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: { defaultModel?: string; channel?: string; capabilities?: string[]; + repoRoot?: string; }; messageToolHints?: string[]; sandboxInfo?: { @@ -570,6 +571,7 @@ export function buildRuntimeLine( node?: string; model?: string; defaultModel?: string; + repoRoot?: string; }, runtimeChannel?: string, runtimeCapabilities: string[] = [], @@ -578,6 +580,7 @@ export function buildRuntimeLine( return `Runtime: ${[ runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", + runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "", runtimeInfo?.os ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` : runtimeInfo?.arch diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 5ba3aedc9..ac93e5fed 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -102,6 +102,8 @@ async function resolveContextReport( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.cfg, agentId: sessionAgentId, + workspaceDir, + cwd: process.cwd(), runtime: { host: "unknown", os: "unknown", diff --git a/src/config/schema.ts b/src/config/schema.ts index cd22e94d9..953205cd6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -197,6 +197,7 @@ const FIELD_LABELS: Record = { "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimestamp": "Envelope Timestamp", @@ -432,6 +433,8 @@ const FIELD_HELP: Record = { "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "agents.defaults.bootstrapMaxChars": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.repoRoot": + "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "agents.defaults.envelopeTimezone": 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', "agents.defaults.envelopeTimestamp": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 11f7cf10d..46bd25d64 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -99,6 +99,8 @@ export type AgentDefaultsConfig = { models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; + /** Optional repository root for system prompt runtime line (overrides auto-detect). */ + repoRoot?: string; /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ skipBootstrap?: boolean; /** Max chars for injected bootstrap files before truncation (default: 20000). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index c6c0ab3b2..fd624bfe3 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z ) .optional(), workspace: z.string().optional(), + repoRoot: z.string().optional(), skipBootstrap: z.boolean().optional(), bootstrapMaxChars: z.number().int().positive().optional(), userTimezone: z.string().optional(), diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index eae2de919..1d40a6ac5 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -226,7 +226,9 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/); + expect(payload.Body).toMatch( + /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + ); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset();