/** * System prompt construction and project context loading */ import * as os from "node:os"; import * as path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai"; import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect"; import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { contextFileCapability } from "./capability/context-file"; import { systemPromptCapability } from "./capability/system-prompt"; import { findConfigFile } from "./config"; import type { Personality, SkillsSettings } from "./config/settings"; import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery"; import { expandAtImports } from "./discovery/at-imports"; import { loadSkills, type Skill } from "./extensibility/skills"; import { hasObsidian } from "./internal-urls/vault-protocol"; import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" }; import computerSafetyPrompt from "./prompts/system/computer-safety.md" with { type: "text" }; import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" }; import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" }; import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" }; import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" with { type: "text" }; import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" }; import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" }; import { normalizeConcurrencyLimit } from "./task/parallel"; import { usesCodexTaskPrompt } from "./task/prompt-policy"; import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context"; import { formatLocalCalendarDate } from "./utils/local-date"; import { normalizePromptPath } from "./utils/prompt-path"; import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree"; /** Bundled personality specs, keyed by the `personality` setting value. */ const PERSONALITY_SPECS: Record, string> = { default: defaultPersonality, friendly: friendlyPersonality, pragmatic: pragmaticPersonality, }; interface AlwaysApplyRule { name: string; content: string; path: string; } function normalizePromptBlock(content: string): string { return prompt.format(content, { renderPhase: "post-render" }).trim(); } function splitComparablePromptBlocks(content: string | null | undefined): string[] { const normalized = firstNonEmpty(content); if (!normalized) return []; return normalizePromptBlock(normalized) .split(/\n{2,}/) .map(block => block.trim()) .filter(block => block.length > 0); } function promptSourceContainsRule(source: string | null | undefined, ruleContent: string): boolean { const sourceBlocks = splitComparablePromptBlocks(source); const ruleBlocks = splitComparablePromptBlocks(ruleContent); if (sourceBlocks.length === 0 || ruleBlocks.length === 0 || ruleBlocks.length > sourceBlocks.length) return false; for (let start = 0; start <= sourceBlocks.length - ruleBlocks.length; start += 1) { if (ruleBlocks.every((block, offset) => sourceBlocks[start + offset] === block)) return true; } return false; } function dedupeAlwaysApplyRules( alwaysApplyRules: AlwaysApplyRule[] | undefined, promptSources: Array, ): AlwaysApplyRule[] { if (!alwaysApplyRules || alwaysApplyRules.length === 0) return []; return alwaysApplyRules.filter( rule => !promptSources.some(source => promptSourceContainsRule(source, rule.content)), ); } function dedupePromptSource(source: string | null | undefined, otherSources: Array): string { const resolvedSource = firstNonEmpty(source); if (!resolvedSource) return ""; return otherSources.some(otherSource => promptSourceContainsRule(otherSource, resolvedSource)) ? "" : resolvedSource; } function firstNonEmpty(...values: (string | undefined | null)[]): string | null { for (const value of values) { const trimmed = value?.trim(); if (trimmed) return trimmed; } return null; } function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string { if (!activeRepoContext) return ""; return prompt .render(activeRepoContextTemplate, { relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot), }) .trim(); } function parseWmicTable(output: string, header: string): string | null { const lines = output .split("\n") .map(line => line.trim()) .filter(Boolean); const filtered = lines.filter(line => line.toLowerCase() !== header.toLowerCase()); return filtered[0] ?? null; } const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000; /** Kept below prep timeout so timed-out probes can still write the null cache before fallback. */ const GPU_PROBE_TIMEOUT_MS = SYSTEM_PROMPT_PREP_TIMEOUT_MS - 500; /** Drop stdout from a probe descendant that inherited the pipe after the probe exited. */ const GPU_PROBE_STDOUT_DRAIN_MS = 250; async function runGpuProbe(cmd: string[]): Promise { try { const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "ignore", stdin: "ignore", timeout: GPU_PROBE_TIMEOUT_MS, // SIGKILL so a probe ignoring SIGTERM (PATH wrapper, wedged WMI) still // dies at the deadline and lets getCachedGpu reach the null-cache write. killSignal: "SIGKILL", }); const stdoutReader = proc.stdout.getReader(); let stdout = ""; const decoder = new TextDecoder(); const stdoutDone = (async () => { while (true) { const chunk = await stdoutReader.read(); if (chunk.done) break; stdout += decoder.decode(chunk.value, { stream: true }); } stdout += decoder.decode(); })(); const exitCode = await proc.exited; // Even on exit 0, a probe wrapper can leave a descendant holding stdout open. // Bound the EOF wait so getCachedGpu cannot outlive the probe in either path; // keep whatever bytes the reader already captured before cancelling. const drained = await Promise.race([ stdoutDone.then(() => "ok" as const).catch(() => "err" as const), Bun.sleep(GPU_PROBE_STDOUT_DRAIN_MS).then(() => "timeout" as const), ]); if (drained !== "ok") { await stdoutReader.cancel().catch(() => undefined); await stdoutDone.catch(() => undefined); } return exitCode === 0 ? stdout : null; } catch { return null; } } async function getGpuModel(): Promise { switch (process.platform) { case "win32": { const output = await runGpuProbe(["wmic", "path", "win32_VideoController", "get", "name"]); return output ? parseWmicTable(output, "Name") : null; } case "linux": { const output = await runGpuProbe(["lspci"]); if (!output) return null; const gpus: Array<{ name: string; priority: number }> = []; for (const line of output.split("\n")) { if (!/(VGA|3D|Display)/i.test(line)) continue; const parts = line.split(":"); const name = parts.length > 1 ? parts.slice(1).join(":").trim() : line.trim(); const nameLower = name.toLowerCase(); // Skip BMC/server management adapters if (/aspeed|matrox g200|mgag200/i.test(name)) continue; // Prioritize discrete GPUs let priority = 0; if ( nameLower.includes("nvidia") || nameLower.includes("geforce") || nameLower.includes("quadro") || nameLower.includes("rtx") ) { priority = 3; } else if (nameLower.includes("amd") || nameLower.includes("radeon") || nameLower.includes("rx ")) { priority = 3; } else if (nameLower.includes("intel")) { priority = 1; } else { priority = 2; } gpus.push({ name, priority }); } if (gpus.length === 0) return null; gpus.sort((a, b) => b.priority - a.priority); return gpus[0].name; } default: return null; } } function getTerminalName(): string | undefined { const termProgram = Bun.env.TERM_PROGRAM; const termProgramVersion = Bun.env.TERM_PROGRAM_VERSION; if (termProgram) { return termProgramVersion ? `${termProgram} ${termProgramVersion}` : termProgram; } if (Bun.env.WT_SESSION) return "Windows Terminal"; const term = firstNonEmpty(Bun.env.TERM, Bun.env.COLORTERM, Bun.env.TERMINAL_EMULATOR); return term ?? undefined; } /** Cached GPU probe result. */ interface GpuCache { gpu: string | null; } async function loadGpuCache(): Promise { try { const cachePath = getGpuCachePath(); const content = await Bun.file(cachePath).json(); if (content && typeof content === "object" && "gpu" in content) { const gpu = content.gpu; return { gpu: typeof gpu === "string" ? gpu : null }; } return null; } catch { return null; } } async function saveGpuCache(info: GpuCache): Promise { try { const cachePath = getGpuCachePath(); await Bun.write(cachePath, JSON.stringify(info, null, "\t")); } catch { // Silently ignore cache write failures } } async function getCachedGpu(): Promise { const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache); if (cached) return cached.gpu ?? undefined; const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel); await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu }); return gpu ?? undefined; } async function getCpuModel(): Promise { if (process.platform !== "linux") return os.cpus()[0]?.model; try { const cpuInfo = await Bun.file("/proc/cpuinfo").text(); const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo); return match?.[1]?.trim() || undefined; } catch (error) { if (!isEnoent(error)) { logger.debug("Could not read Linux CPU model", { error: String(error) }); } return undefined; } } /** * Kernel identity for the workstation block. Prefers the uname build string * from `os.version()`, but Bun on macOS 15+ (Darwin 24/25) returns the literal * `"unknown"` when `uv_os_uname()`'s `version` field is empty — which surfaces * `Kernel: unknown` in the system prompt and makes the model misidentify the * host as Windows (#4141). Fall back to ` ` (uname -s + -r) so * macOS is always tagged as `Darwin ` and Linux keeps its build info. */ function getKernelIdentity(): string { const version = os.version()?.trim(); if (version && version.toLowerCase() !== "unknown") return version; return `${os.type()} ${os.release()}`.trim(); } function getEnvironmentInfo( cpuModel: string | undefined, gpu: string | undefined, ): Array<{ label: string; value: string }> { const entries: Array<{ label: string; value: string | undefined }> = [ { label: "OS", value: `${os.platform()} ${os.release()}` }, { label: "Distro", value: os.type() }, { label: "Kernel", value: getKernelIdentity() }, { label: "Arch", value: os.arch() }, { label: "CPU", value: cpuModel }, { label: "GPU", value: gpu }, { label: "Terminal", value: getTerminalName() }, ]; return entries.filter((e): e is { label: string; value: string } => !!e.value); } /** Discover TITLE_SYSTEM.md file for automatic session-title prompt overrides */ export function discoverTitleSystemPromptFile(cwd?: string): string | undefined { const projectPath = findConfigFile("TITLE_SYSTEM.md", { user: false, cwd }); if (projectPath) { return projectPath; } const globalPath = findConfigFile("TITLE_SYSTEM.md", { user: true, cwd }); if (globalPath) { return globalPath; } return undefined; } /** Resolve input as file path or literal string */ export async function resolvePromptInput(input: string | undefined, description: string): Promise { if (!input) { return undefined; } else if (input.includes("\n")) { return input; } try { return await Bun.file(input).text(); } catch (error) { if (!hasFsCode(error, "ENAMETOOLONG") && !isEnoent(error)) { logger.warn(`Could not read ${description} file`, { path: input, error: String(error) }); } return input; } } export interface LoadContextFilesOptions { /** Working directory to start walking up from. Default: getProjectDir() */ cwd?: string; /** Disabled extension IDs to honor instead of the process-global settings. */ disabledExtensions?: string[]; } function dedupeExactContextFiles( contextFiles: Array<{ path: string; content: string; depth?: number }>, ): Array<{ path: string; content: string; depth?: number }> { const lastIndexByContent = new Map(); for (const [index, file] of contextFiles.entries()) { // Keep the closest matching context entry when content is byte-for-byte identical. lastIndexByContent.set(file.content, index); } return contextFiles.filter((file, index) => lastIndexByContent.get(file.content) === index); } /** * Load all project context files using the capability API. * Returns {path, content, depth} entries for all discovered context files. * Files are sorted by depth (descending) so files closer to cwd appear last/more prominent. */ export async function loadProjectContextFiles( options: LoadContextFilesOptions = {}, ): Promise> { const resolvedCwd = options.cwd ?? getProjectDir(); const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd, disabledExtensions: options.disabledExtensions, }); // Materialize ContextFile items, expanding any `@path/to/file` includes // in their content. The expansion uses the file's own directory as the // resolution base so relative imports work the same way Claude Code, // Goose, and other tools document. const files = await Promise.all( result.items.map(async item => { const contextFile = item as ContextFile; return { path: contextFile.path, content: await expandAtImports(contextFile.content, contextFile.path), depth: contextFile.depth, }; }), ); // Sort by depth (descending): higher depth (farther from cwd) comes first, // so files closer to cwd appear later and are more prominent files.sort((a, b) => { const depthA = a.depth ?? -1; const depthB = b.depth ?? -1; return depthB - depthA; }); return dedupeExactContextFiles(files); } /** * Load the effective system prompt customization from SYSTEM.md. * Project-level SYSTEM.md overrides user-level SYSTEM.md. */ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise { const resolvedCwd = options.cwd ?? getProjectDir(); const result = await loadCapability(systemPromptCapability.id, { cwd: resolvedCwd }); if (result.items.length === 0) return null; const projectLevel = result.items.find(item => item.level === "project"); if (projectLevel) { return projectLevel.content; } const userLevel = result.items.find(item => item.level === "user"); return userLevel?.content ?? null; } export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const; export interface SystemPromptToolMetadata { label: string; description: string; /** Tool name the model sees on the provider wire. Defaults to the internal tool name. */ wireName?: string; /** Tool parameters schema (Zod or JSON Schema), fed to the verbose inventory renderer. */ parameters?: TSchema; /** Illustrative examples rendered into the verbose inventory. */ examples?: readonly ToolExample[]; } export type SystemPromptToolMetadataProjection = | { mode: "compact"; toolNames: readonly string[]; overrides?: Partial>>; } | { mode: "full"; overrides?: Partial>>; }; export function buildSystemPromptToolMetadata( tools: Map, overrides: Partial>> = {}, ): Map { return projectSystemPromptToolMetadata(tools, { mode: "full", overrides }); } /** Builds a mode-specific metadata snapshot for internal prompt assembly. */ export function projectSystemPromptToolMetadata( tools: Map, projection: SystemPromptToolMetadataProjection, ): Map { const metadata = new Map(); const addTool = (name: string, tool: AgentTool): void => { const override = projection.overrides?.[name]; const labelValue = override?.label ?? tool.label; const wireNameValue = override?.wireName ?? tool.customWireName; const label = typeof labelValue === "string" ? labelValue : ""; const wireName = typeof wireNameValue === "string" ? wireNameValue : undefined; if (projection.mode === "compact") { metadata.set(name, { label, description: "", wireName }); return; } const descriptionValue = override?.description ?? tool.description; metadata.set(name, { label, description: typeof descriptionValue === "string" ? descriptionValue : "", parameters: tool.parameters, examples: tool.examples, wireName, }); }; if (projection.mode === "compact") { for (const name of projection.toolNames) { const tool = tools.get(name); if (tool) addTool(name, tool); } } else { for (const [name, tool] of tools) addTool(name, tool); } return metadata; } export interface BuildSystemPromptOptions { /** Custom system prompt (replaces default). */ customPrompt?: string; /** Already-loaded custom system prompt text; bypasses path resolution. */ resolvedCustomPrompt?: string; /** Tools to include in prompt. */ tools?: Map; /** Tool names to include in prompt. */ toolNames?: string[]; /** Text to append to system prompt. */ appendSystemPrompt?: string; /** Already-loaded append prompt text; bypasses path resolution. */ resolvedAppendSystemPrompt?: string; /** Inline full tool descriptors in the system prompt. Default: false */ inlineToolDescriptors?: boolean; /** * Whether provider-native tool calling is active (no owned/in-band syntax). * When true and `inlineToolDescriptors` is false, the inventory renders as a * compact tool-name list; otherwise it renders the full Harmony-style * `namespace functions { … }` catalog. Default: true */ nativeTools?: boolean; /** Skills settings for discovery. */ skillsSettings?: SkillsSettings; /** Working directory. Default: getProjectDir() */ cwd?: string; /** Additional workspace directories beyond cwd (multi-root), absolute. Injected into the project prompt. */ additionalWorkspaceRoots?: string[]; /** Pre-loaded context files (skips discovery if provided). */ contextFiles?: Array<{ path: string; content: string; depth?: number }>; /** Skills provided directly to system prompt construction. */ skills?: readonly Skill[]; /** Pre-loaded rulebook rules (descriptions, excluding TTSR and always-apply). */ rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>; /** Intent field name injected into every tool schema. If set, explains the field in the prompt. */ intentField?: string; /** Encourage the agent to delegate via tasks unless changes are trivial. */ eagerTasks?: boolean; /** When true, the Eager Tasks section uses the hard MUST/ONLY wording (`task.eager: always`) rather than the softer `preferred` nudge. */ eagerTasksAlways?: boolean; /** Whether `task.batch` is enabled; selects the centralized delegation guidance's call shape. */ taskBatch?: boolean; /** Effective task concurrency limit displayed in centralized delegation guidance. Zero means unlimited. */ taskMaxConcurrency?: number; /** Whether IRC-backed parallel coordination can be included in delegation policy. */ taskIrcEnabled?: boolean; /** Whether the read-only `scout` subagent is spawnable (not disabled, allowed by spawn policy). Defaults to true. */ scoutAvailable?: boolean; /** Rules with alwaysApply=true — their full content is injected into the prompt. */ alwaysApplyRules?: AlwaysApplyRule[]; /** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */ secretsEnabled?: boolean; /** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */ workspaceTree?: WorkspaceTree | Promise; /** Whether the local memory://root summary is active. */ memoryRootEnabled?: boolean; /** Whether the read-only security:// resource namespace is active. */ securityEnabled?: boolean; /** Active model identifier (e.g. "anthropic/claude-opus-4") used by prompt policy and optionally surfaced. */ model?: string; /** Whether to surface `model` in the workstation block. Model-specific prompt policy still uses it. Default: true. */ includeModelInPrompt?: boolean; /** Personality preset rendered into the default system prompt. "none" omits the block. Default: "default" */ personality?: Personality; /** Whether to include the workspace directory tree in the system prompt. Default: false */ includeWorkspaceTree?: boolean; /** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */ renderMermaid?: boolean; /** Pre-resolved nested active repo context. Undefined resolves from cwd. */ activeRepoContext?: ActiveRepoContext | null; /** Tools mounted under `xd://`; renders the protocol section when non-empty. `dynamic` marks external devices whose summary is third-party metadata. */ xdevTools?: Array<{ name: string; summary: string; dynamic?: boolean }>; /** Full docs + JSON schema for every `xd://`-mounted tool, inlined into the protocol section so no discovery `read` is needed. */ xdevDocs?: string; /** Whether Auto-QA grievance reporting is enabled; renders the `xd://report_issue` note. */ autoQaEnabled?: boolean; } /** Result of building provider-facing system prompt messages. */ export interface BuildSystemPromptResult { /** Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks. */ systemPrompt: string[]; /** * Names of `xd://` devices whose catalog/protocol section this prompt renders. * Empty/undefined when no catalog was emitted (no mounted devices, or a custom * prompt template that omits the section). Lets the session fold these devices * into its announced-mount baseline so a same-turn mount notice does not re-list * a catalog the prompt already carries (issue #7139). */ xdevCatalogNames?: readonly string[]; } /** Build the system prompt with tools, guidelines, and context */ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise { if ($env.NULL_PROMPT === "true") { return { systemPrompt: [] }; } const { customPrompt, resolvedCustomPrompt: providedResolvedCustomPrompt, tools, appendSystemPrompt, inlineToolDescriptors: providedInlineToolDescriptors, resolvedAppendSystemPrompt: providedResolvedAppendPrompt, nativeTools = true, skillsSettings, toolNames: providedToolNames, cwd, additionalWorkspaceRoots = [], contextFiles: providedContextFiles, skills: providedSkills, rules, alwaysApplyRules, intentField, eagerTasks = false, eagerTasksAlways = false, taskBatch = true, taskMaxConcurrency = 0, taskIrcEnabled = false, secretsEnabled = false, workspaceTree: providedWorkspaceTree, scoutAvailable = true, memoryRootEnabled = false, securityEnabled = false, model, includeModelInPrompt = true, personality = "default", includeWorkspaceTree = false, renderMermaid = true, xdevTools = [], xdevDocs = "", autoQaEnabled = false, activeRepoContext: providedActiveRepoContext, } = options; const inlineToolDescriptors = providedInlineToolDescriptors ?? false; const resolvedCwd = cwd ?? getProjectDir(); const prepDefaults = { resolvedCustomPrompt: undefined as string | undefined, resolvedAppendPrompt: undefined as string | undefined, systemPromptCustomization: null as string | null, contextFiles: dedupeExactContextFiles(providedContextFiles ?? []), skills: providedSkills ?? ([] as Skill[]), workspaceTree: { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [], } satisfies WorkspaceTree, activeRepoContext: null as ActiveRepoContext | null, cpuModel: undefined as string | undefined, gpu: undefined as string | undefined, }; const { promise: deadline, resolve: fireDeadline } = Promise.withResolvers<"__timeout__">(); const deadlineTimer = setTimeout(() => fireDeadline("__timeout__"), SYSTEM_PROMPT_PREP_TIMEOUT_MS); // Unref so a fast prep does not hold a one-shot CLI alive waiting for this timer. deadlineTimer.unref(); const timedOut: string[] = []; const failed: Array<{ name: string; error: unknown }> = []; async function withDeadline(name: string, work: Promise, fallback: T): Promise { const tagged = work .then(value => ({ kind: "ok" as const, value })) .catch(error => ({ kind: "err" as const, error })); const result = await Promise.race([tagged, deadline]); if (result === "__timeout__") { timedOut.push(name); // Let the work continue in the background so its caches still warm; just log on completion. void tagged.then(r => { if (r.kind === "err") { logger.warn("Background system prompt preparation step failed", { name, error: String(r.error) }); } else { logger.debug("Background system prompt preparation step completed after timeout", { name }); } }); return fallback; } if (result.kind === "err") { failed.push({ name, error: result.error }); return fallback; } return result.value; } // Caller-supplied `customPrompt` / `resolvedCustomPrompt` owns block 0; the // secondary capability-path `SYSTEM.md` walk-up MUST NOT silently augment it, // because that would defeat CLI precedence over project/user `SYSTEM.md`. const callerControlsCustomPrompt = (typeof providedResolvedCustomPrompt === "string" && providedResolvedCustomPrompt.length > 0) || (typeof customPrompt === "string" && customPrompt.length > 0); const systemPromptCustomizationPromise: Promise = callerControlsCustomPrompt ? Promise.resolve(null) : logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd }); const contextFilesPromise = (async () => { const primary = providedContextFiles ? providedContextFiles : await logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); // Also discover context files (AGENTS.md, rules, etc.) for each additional workspace root. const additionalRoots = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); if (additionalRoots.length === 0) return primary; const extra = await Promise.all( additionalRoots.map(root => loadProjectContextFiles({ cwd: root }).catch(() => [])), ); return dedupeExactContextFiles([...primary, ...extra.flat()]); })(); const additionalRootsForTree = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); const workspaceTreePromise = (async () => { const primary = providedWorkspaceTree !== undefined ? await Promise.resolve(providedWorkspaceTree) : includeWorkspaceTree ? await logger.time("buildWorkspaceTree", () => buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }), ) : { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }; if (additionalRootsForTree.length === 0 || !includeWorkspaceTree) return primary; const extraTrees = await Promise.all( additionalRootsForTree.map(root => buildWorkspaceTree(root, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }).catch(() => ({ rootPath: root, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [], })), ), ); return { ...primary, agentsMdFiles: [...primary.agentsMdFiles, ...extraTrees.flatMap(t => t.agentsMdFiles)] }; })(); const skillsPromise: Promise = providedSkills !== undefined ? Promise.resolve(providedSkills) : skillsSettings?.enabled !== false ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills) : Promise.resolve([]); const activeRepoContextPromise = providedActiveRepoContext !== undefined ? Promise.resolve(providedActiveRepoContext) : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd)); const cpuModelPromise = logger.time("getCpuModel", getCpuModel); const gpuPromise = logger.time("getCachedGpu", getCachedGpu); const [ resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree, activeRepoContext, cpuModel, gpu, ] = await Promise.all([ withDeadline( "customPrompt", providedResolvedCustomPrompt !== undefined ? Promise.resolve(providedResolvedCustomPrompt) : resolvePromptInput(customPrompt, "system prompt"), prepDefaults.resolvedCustomPrompt, ), withDeadline( "appendSystemPrompt", providedResolvedAppendPrompt !== undefined ? Promise.resolve(providedResolvedAppendPrompt) : resolvePromptInput(appendSystemPrompt, "append system prompt"), prepDefaults.resolvedAppendPrompt, ), withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization), withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then( dedupeExactContextFiles, ), withDeadline("loadSkills", skillsPromise, prepDefaults.skills), withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree), withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext), withDeadline("getCpuModel", cpuModelPromise, prepDefaults.cpuModel), withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu), ]); clearTimeout(deadlineTimer); const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT); if (timedOut.length > 0) { logger.warn("System prompt preparation steps timed out; using minimal fallback for those steps", { cwd: resolvedCwd, timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS, steps: timedOut, }); process.stderr.write( `Warning: system prompt preparation steps timed out after ${SYSTEM_PROMPT_PREP_TIMEOUT_MS}ms (${timedOut.join(", ")}); using minimal fallback for those steps.\n`, ); } if (failed.length > 0) { for (const { name, error } of failed) { logger.warn("System prompt preparation step failed; using minimal fallback", { cwd: resolvedCwd, step: name, error: String(error), }); } } const date = formatLocalCalendarDate(); const dateTime = date; const promptCwd = normalizePromptPath(resolvedCwd); const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext); // Build tool metadata for system prompt rendering. // Priority: explicit list > tools map > conservative SDK fallback. let toolNames = providedToolNames; if (!toolNames) { toolNames = tools ? Array.from(tools.keys()) : [...DEFAULT_SYSTEM_PROMPT_TOOL_NAMES]; } // List mode shows a compact tool-name list; it only applies when descriptors // stay in provider-native tool schemas AND native tool calling is active. // Otherwise render the full functions-namespace catalog in the system prompt. const toolListMode = !inlineToolDescriptors && nativeTools; // Build tool descriptions for system prompt rendering. const toolPromptNames = new Map(toolNames.map(name => [name, tools?.get(name)?.wireName ?? name])); // xd://-mounted tools count as present for prompt gates ({{#has tools "lsp"}}) // and resolve their own name as the reference — the xd:// section explains // the access path. The Tool Inventory list stays limited to real defs. for (const mounted of xdevTools) { if (!toolPromptNames.has(mounted.name)) toolPromptNames.set(mounted.name, mounted.name); } const toolRefs = Object.fromEntries(toolPromptNames.entries()); const xdevToolNames = new Set(xdevTools.map(mounted => mounted.name)); // A direct custom tool can share a name with a retained built-in device. // Presence in both toolNames and tools proves it still has a top-level definition. const inventoryToolNames = xdevToolNames.size === 0 ? toolNames : toolNames.filter(name => tools?.has(name) || !xdevToolNames.has(name)); const toolInfo = inventoryToolNames.map(name => ({ name: toolPromptNames.get(name) ?? name, internalName: name, label: tools?.get(name)?.label ?? "", })); const toolInventory = toolListMode ? "" : renderToolInventory( inventoryToolNames.map(name => { const meta = tools?.get(name); return { name: toolPromptNames.get(name) ?? name, description: meta?.description ?? "", parameters: meta?.parameters ?? ({ type: "object" } as TSchema), examples: meta?.examples, }; }), ); // Filter skills for the rendered system prompt: // - require the `read` tool so the model can actually fetch skill content; // - drop skills with frontmatter `hide: true` (still loadable via skill:// and /skill:). const hasRead = toolNames.includes("read"); const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : []; const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [ resolvedCustomPrompt, resolvedAppendPrompt, ]); const contextPromptSources = contextFiles.map(file => file.content); const promptSources = [ effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt, ...contextPromptSources, ]; const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources); const environment = getEnvironmentInfo(cpuModel, gpu); const data = { systemPromptCustomization: effectiveSystemPromptCustomization, customPrompt: resolvedCustomPrompt, appendPrompt: resolvedAppendPrompt ?? "", tools: [...new Set([...toolNames, ...xdevTools.map(mounted => mounted.name)])], toolInfo, toolInventory, inlineToolDescriptors, toolListMode, toolRefs, environment, contextFiles, agentsMdSearch: { files: agentsMdFiles }, workspaceTree, skills: filteredSkills, rules: rules ?? [], alwaysApplyRules: injectedAlwaysApplyRules, date, dateTime, cwd: promptCwd, additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)), model: includeModelInPrompt ? (model ?? "") : "", useCodexTaskPrompt: usesCodexTaskPrompt(model), personality: personality === "none" ? "" : PERSONALITY_SPECS[personality].trim(), intentTracing: !!intentField, intentField: intentField ?? "", eagerTasks, eagerTasksAlways, taskBatch, MAX_CONCURRENCY: normalizeConcurrencyLimit(taskMaxConcurrency), scoutAvailable, taskIrcEnabled, secretsEnabled, hasMemoryRoot: memoryRootEnabled, securityEnabled, hasObsidian: hasObsidian(), includeWorkspaceTree, renderMermaid, xdevTools, hasDynamicXdevTools: xdevTools.some(mounted => mounted.dynamic === true), xdevDocs, autoQaEnabled, }; const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); const systemPrompt = [rendered]; if (toolNames.includes("computer")) { systemPrompt.push(computerSafetyPrompt.trim()); } // Custom prompt templates already render context files and append text; the // project footer still carries environment, cwd, workspace, and dir-context. const projectPrompt = prompt .render(projectPromptTemplate, resolvedCustomPrompt ? { ...data, contextFiles: [], appendPrompt: "" } : data) .trim(); if (projectPrompt) { systemPrompt.push(projectPrompt); } if (activeRepoContextPrompt) { systemPrompt.push(activeRepoContextPrompt); } // The xd:// protocol section (with its device catalog) is only rendered by the // default template; a resolved custom prompt uses a template that omits it. const xdevCatalogNames = !resolvedCustomPrompt && xdevTools.length > 0 ? xdevTools.map(mounted => mounted.name) : undefined; return { systemPrompt, xdevCatalogNames }; }