Files
oh-my-pi/packages/coding-agent/src/core/system-prompt.ts
T
can1357 b2c94bef1a refactor(deps): restructured codebase to use centralized pi-utils package
- Created new @oh-my-pi/pi-utils workspace package with shared utilities for logging, process management, stream handling, and temporary directory management.
- Migrated all packages to use centralized logger from @oh-my-pi/pi-utils instead of local winston implementations.
- Replaced custom process spawning and stream reading implementations with standardized cspawn and readLines utilities across all modules.
- Converted synchronous file operations and process spawning to async patterns using Bun shell syntax and fs/promises.
- Added streaming edit abort functionality with configurable setting to abort on patch preview failures.
- Updated test framework from vitest to bun:test across all test suites.
2026-01-20 05:33:21 +01:00

777 lines
22 KiB
TypeScript

/**
* System prompt construction and project context loading
*/
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
import chalk from "chalk";
import { contextFileCapability } from "../capability/context-file";
import { systemPromptCapability } from "../capability/system-prompt";
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "../discovery/index";
import customSystemPromptTemplate from "../prompts/system/custom-system-prompt.md" with { type: "text" };
import systemPromptTemplate from "../prompts/system/system-prompt.md" with { type: "text" };
import { renderPromptTemplate } from "./prompt-templates";
import type { SkillsSettings } from "./settings-manager";
import { loadSkills, type Skill } from "./skills";
import type { ToolName } from "./tools/index";
interface GitContext {
isRepo: boolean;
currentBranch: string;
mainBranch: string;
status: string;
commits: string;
}
/**
* Load git context for the system prompt.
* Returns structured git data or null if not in a git repo.
*/
export async function loadGitContext(cwd: string): Promise<GitContext | null> {
const git = (...args: string[]) =>
$`git ${args}`
.cwd(cwd)
.quiet()
.text()
.catch(() => null)
.then((text) => text?.trim() ?? null);
// Check if inside a git repo
const isGitRepo = await git("rev-parse", "--is-inside-work-tree");
if (isGitRepo !== "true") return null;
// Get current branch
const currentBranch = await git("rev-parse", "--abbrev-ref", "HEAD");
if (!currentBranch) return null;
// Detect main branch (check for 'main' first, then 'master')
let mainBranch = "main";
const mainExists = await git("rev-parse", "--verify", "main");
if (mainExists === null) {
const masterExists = await git("rev-parse", "--verify", "master");
if (masterExists !== null) mainBranch = "master";
}
// Get git status (porcelain format for parsing)
const status = (await git("status", "--porcelain")) || "(clean)";
// Get recent commits
const commits = (await git("log", "--oneline", "-5")) || "(no commits)";
return {
isRepo: true,
currentBranch,
mainBranch,
status,
commits,
};
}
/** Tool descriptions for system prompt */
const toolDescriptions: Record<ToolName, string> = {
ask: "Ask user for input or clarification",
read: "Read file contents",
bash: "Execute bash commands (npm, docker, etc.)",
python: "Execute Python code via a session-backed IPython kernel",
calc: "{ calculations: array of { expression: string, prefix: string, suffix: string } } Basic calculations.",
ssh: "Execute commands on remote hosts via SSH",
edit: "Make surgical edits to files (find exact text and replace)",
write: "Create or overwrite files",
grep: "Search file contents for patterns (respects .gitignore)",
find: "Find files by glob pattern (respects .gitignore)",
git: "Structured Git operations with safety guards (status, diff, log, commit, push, pr, etc.)",
ls: "List directory contents",
lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.",
notebook: "Edit Jupyter notebook cells",
output: "Output structured data to the user (bypasses tool result formatting)",
task: "Spawn a sub-agent to handle complex tasks",
web_fetch: "Fetch and render URLs into clean text for LLM consumption",
web_search: "Search the web for information",
report_finding: "Report a finding during code review",
};
function firstNonEmpty(values: Array<string | undefined | null>): string | null {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return null;
}
function firstNonEmptyLine(value: string | null): string | null {
if (!value) return null;
const line = value
.split("\n")
.map((entry) => entry.trim())
.filter(Boolean)[0];
return line ?? null;
}
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;
}
function parseKeyValueOutput(output: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const [key, ...rest] = trimmed.split("=");
if (!key || rest.length === 0) continue;
const value = rest.join("=").trim();
if (value) result[key.trim()] = value;
}
return result;
}
function stripQuotes(value: string): string {
return value.replace(/^"|"$/g, "");
}
const AGENTS_MD_PATTERN = "**/AGENTS.md";
const AGENTS_MD_LIMIT = 200;
interface AgentsMdSearch {
scopePath: string;
limit: number;
pattern: string;
files: string[];
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/");
}
function listAgentsMdFiles(root: string, limit: number): string[] {
try {
const entries = Array.from(
new Bun.Glob(AGENTS_MD_PATTERN).scanSync({ cwd: root, onlyFiles: true, dot: false, absolute: false }),
);
const normalized = entries
.map((entry) => normalizePath(entry))
.filter((entry) => entry.length > 0 && !entry.includes("node_modules"))
.sort();
return normalized.length > limit ? normalized.slice(0, limit) : normalized;
} catch {
return [];
}
}
function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
const files = listAgentsMdFiles(cwd, AGENTS_MD_LIMIT);
return {
scopePath: ".",
limit: AGENTS_MD_LIMIT,
pattern: AGENTS_MD_PATTERN,
files,
};
}
function getOsName(): string {
switch (process.platform) {
case "win32":
return "Windows";
case "darwin":
return "macOS";
case "linux":
return "Linux";
case "freebsd":
return "FreeBSD";
case "openbsd":
return "OpenBSD";
case "netbsd":
return "NetBSD";
case "aix":
return "AIX";
default:
return process.platform || "unknown";
}
}
async function getKernelVersion(): Promise<string> {
if (process.platform === "win32") {
return await $`ver`
.quiet()
.text()
.catch(() => "unknown");
} else {
return await $`uname -sr`
.quiet()
.text()
.catch(() => "unknown");
}
}
async function getOsDistro(): Promise<string | null> {
switch (process.platform) {
case "win32": {
const output = await $`wmic os get Caption,Version /value`
.quiet()
.text()
.catch(() => null);
if (!output) return null;
const parsed = parseKeyValueOutput(output);
const caption = parsed.Caption;
const version = parsed.Version;
if (caption && version) return `${caption} ${version}`.trim();
return caption ?? version ?? null;
}
case "darwin": {
const name = firstNonEmptyLine(
await $`sw_vers -productName`
.quiet()
.text()
.catch(() => null),
);
const version = firstNonEmptyLine(
await $`sw_vers -productVersion`
.quiet()
.text()
.catch(() => null),
);
if (name && version) return `${name} ${version}`.trim();
return name ?? version ?? null;
}
case "linux": {
const lsb = firstNonEmptyLine(
await $`lsb_release -ds`
.quiet()
.text()
.catch(() => null),
);
if (lsb) return stripQuotes(lsb);
const osRelease = await Bun.file("/etc/os-release")
.text()
.catch(() => null);
if (!osRelease) return null;
const parsed = parseKeyValueOutput(osRelease);
const pretty = parsed.PRETTY_NAME ?? parsed.NAME;
const version = parsed.VERSION ?? parsed.VERSION_ID;
if (pretty) return stripQuotes(pretty);
if (parsed.NAME && version) return `${stripQuotes(parsed.NAME)} ${stripQuotes(version)}`.trim();
return parsed.NAME ? stripQuotes(parsed.NAME) : null;
}
default:
return null;
}
}
function getCpuArch(): string {
return process.arch || "unknown";
}
async function getCpuModel(): Promise<string | null> {
switch (process.platform) {
case "win32": {
const output = await $`wmic cpu get Name`
.quiet()
.text()
.catch(() => null);
return output ? parseWmicTable(output, "Name") : null;
}
case "darwin": {
return firstNonEmptyLine(
await $`sysctl -n machdep.cpu.brand_string`
.quiet()
.text()
.catch(() => null),
);
}
case "linux": {
const lscpu = await $`lscpu`
.quiet()
.text()
.catch(() => null);
if (lscpu) {
const match = lscpu
.split("\n")
.map((line) => line.trim())
.find((line) => line.toLowerCase().startsWith("model name:"));
if (match) return match.split(":").slice(1).join(":").trim();
}
const cpuInfo = await Bun.file("/proc/cpuinfo")
.text()
.catch(() => null);
if (!cpuInfo) return null;
for (const line of cpuInfo.split("\n")) {
const [key, ...rest] = line.split(":");
if (!key || rest.length === 0) continue;
const normalized = key.trim().toLowerCase();
if (normalized === "model name" || normalized === "hardware" || normalized === "processor") {
return rest.join(":").trim();
}
}
return null;
}
default:
return null;
}
}
async function getGpuModel(): Promise<string | null> {
switch (process.platform) {
case "win32": {
const output = await $`wmic path win32_VideoController get name`
.quiet()
.text()
.catch(() => null);
return output ? parseWmicTable(output, "Name") : null;
}
case "linux": {
const output = await $`lspci`
.quiet()
.text()
.catch(() => null);
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 getShellName(): string {
const shell = firstNonEmpty([process.env.SHELL, process.env.ComSpec]);
return shell ?? "unknown";
}
function getTerminalName(): string {
const termProgram = process.env.TERM_PROGRAM;
const termProgramVersion = process.env.TERM_PROGRAM_VERSION;
if (termProgram) {
return termProgramVersion ? `${termProgram} ${termProgramVersion}` : termProgram;
}
if (process.env.WT_SESSION) return "Windows Terminal";
const term = firstNonEmpty([process.env.TERM, process.env.COLORTERM, process.env.TERMINAL_EMULATOR]);
return term ?? "unknown";
}
function normalizeDesktopValue(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "unknown";
const parts = trimmed
.split(":")
.map((part) => part.trim())
.filter(Boolean);
return parts[0] ?? trimmed;
}
function getDesktopEnvironment(): string {
if (process.env.KDE_FULL_SESSION === "true") return "KDE";
const raw = firstNonEmpty([
process.env.XDG_CURRENT_DESKTOP,
process.env.DESKTOP_SESSION,
process.env.XDG_SESSION_DESKTOP,
process.env.GDMSESSION,
]);
return raw ? normalizeDesktopValue(raw) : "unknown";
}
function matchKnownWindowManager(value: string): string | null {
const normalized = value.toLowerCase();
const candidates = [
"sway",
"i3",
"i3wm",
"bspwm",
"openbox",
"awesome",
"herbstluftwm",
"fluxbox",
"icewm",
"dwm",
"hyprland",
"wayfire",
"river",
"labwc",
"qtile",
];
for (const candidate of candidates) {
if (normalized.includes(candidate)) return candidate;
}
return null;
}
function getWindowManager(): string {
const explicit = firstNonEmpty([process.env.WINDOWMANAGER]);
if (explicit) return explicit;
const desktop = firstNonEmpty([process.env.XDG_CURRENT_DESKTOP, process.env.DESKTOP_SESSION]);
if (desktop) {
const matched = matchKnownWindowManager(desktop);
if (matched) return matched;
}
return "unknown";
}
/** Cached system info structure */
interface SystemInfoCache {
os: string;
distro: string;
kernel: string;
arch: string;
cpu: string;
gpu: string;
disk: string;
}
function getSystemInfoCachePath(): string {
return join(homedir(), ".omp", "system_info.json");
}
async function loadSystemInfoCache(): Promise<SystemInfoCache | null> {
try {
const cachePath = getSystemInfoCachePath();
if (!existsSync(cachePath)) return null;
const content = await Bun.file(cachePath).json();
return content as SystemInfoCache;
} catch {
return null;
}
}
async function saveSystemInfoCache(info: SystemInfoCache): Promise<void> {
try {
const cachePath = getSystemInfoCachePath();
await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
} catch {
// Silently ignore cache write failures
}
}
async function collectSystemInfo(): Promise<SystemInfoCache> {
const [distro, cpu, gpu, disk, kernel] = await Promise.all([
getOsDistro(),
getCpuModel(),
getGpuModel(),
getDiskInfo(),
getKernelVersion(),
]);
return {
os: getOsName(),
distro: distro ?? "unknown",
kernel: kernel ?? "unknown",
arch: getCpuArch(),
cpu: cpu ?? "unknown",
gpu: gpu ?? "unknown",
disk: disk ?? "unknown",
};
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)}TB`;
}
async function getDiskInfo(): Promise<string | null> {
switch (process.platform) {
case "win32": {
const output = await $`wmic logicaldisk get Caption,Size,FreeSpace /format:csv`
.quiet()
.text()
.catch(() => null);
if (!output) return null;
const lines = output.split("\n").filter((l) => l.trim() && !l.startsWith("Node"));
const disks: string[] = [];
for (const line of lines) {
const parts = line.split(",");
if (parts.length < 4) continue;
const caption = parts[1]?.trim();
const freeSpace = Number.parseInt(parts[2]?.trim() ?? "", 10);
const size = Number.parseInt(parts[3]?.trim() ?? "", 10);
if (!caption || Number.isNaN(size) || size === 0) continue;
const used = size - (Number.isNaN(freeSpace) ? 0 : freeSpace);
const pct = Math.round((used / size) * 100);
disks.push(`${caption} ${formatBytes(used)}/${formatBytes(size)} (${pct}%)`);
}
return disks.length > 0 ? disks.join(", ") : null;
}
case "linux":
case "darwin": {
const output = await $`df -h /`
.quiet()
.text()
.catch(() => null);
if (!output) return null;
const lines = output.split("\n");
if (lines.length < 2) return null;
const parts = lines[1].split(/\s+/);
if (parts.length < 5) return null;
const size = parts[1];
const used = parts[2];
const pct = parts[4];
return `/ ${used}/${size} (${pct})`;
}
default:
return null;
}
}
async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
// Load cached system info or collect fresh
let sysInfo = await loadSystemInfoCache();
if (!sysInfo) {
sysInfo = await collectSystemInfo();
await saveSystemInfoCache(sysInfo);
}
return [
{ label: "OS", value: sysInfo.os },
{ label: "Distro", value: sysInfo.distro },
{ label: "Kernel", value: sysInfo.kernel },
{ label: "Arch", value: sysInfo.arch },
{ label: "CPU", value: sysInfo.cpu },
{ label: "GPU", value: sysInfo.gpu },
{ label: "Disk", value: sysInfo.disk },
{ label: "Shell", value: getShellName() },
{ label: "Terminal", value: getTerminalName() },
{ label: "DE", value: getDesktopEnvironment() },
{ label: "WM", value: getWindowManager() },
];
}
/** Resolve input as file path or literal string */
export async function resolvePromptInput(input: string | undefined, description: string): Promise<string | undefined> {
if (!input) {
return undefined;
}
const file = Bun.file(input);
if (await file.exists()) {
try {
return await file.text();
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
return input;
}
export interface LoadContextFilesOptions {
/** Working directory to start walking up from. Default: process.cwd() */
cwd?: string;
}
/**
* 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<Array<{ path: string; content: string; depth?: number }>> {
const resolvedCwd = options.cwd ?? process.cwd();
const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
// Convert ContextFile items and preserve depth info
const files = result.items.map((item) => {
const contextFile = item as ContextFile;
return {
path: contextFile.path,
content: contextFile.content,
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 files;
}
/**
* Load system prompt customization files (SYSTEM.md).
* Returns combined content from all discovered SYSTEM.md files.
*/
export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise<string | null> {
const resolvedCwd = options.cwd ?? process.cwd();
const result = await loadCapability<SystemPromptFile>(systemPromptCapability.id, { cwd: resolvedCwd });
if (result.items.length === 0) return null;
// Combine all SYSTEM.md contents (user-level first, then project-level)
const userLevel = result.items.filter((item) => item.level === "user");
const projectLevel = result.items.filter((item) => item.level === "project");
const parts: string[] = [];
for (const item of [...userLevel, ...projectLevel]) {
parts.push(item.content);
}
return parts.join("\n\n");
}
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
customPrompt?: string;
/** Tools to include in prompt. */
tools?: Map<string, { description: string; label: string }>;
/** Tool names to include in prompt. */
toolNames?: string[];
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Skills settings for discovery. */
skillsSettings?: SkillsSettings;
/** Working directory. Default: process.cwd() */
cwd?: string;
/** Pre-loaded context files (skips discovery if provided). */
contextFiles?: Array<{ path: string; content: string; depth?: number }>;
/** Pre-loaded skills (skips discovery if provided). */
skills?: Skill[];
/** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
}
/** Build the system prompt with tools, guidelines, and context */
export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<string> {
if (process.env.NULL_PROMPT === "true") {
return "";
}
const {
customPrompt,
tools,
appendSystemPrompt,
skillsSettings,
toolNames,
cwd,
contextFiles: providedContextFiles,
skills: providedSkills,
rules,
} = options;
const resolvedCwd = cwd ?? process.cwd();
const resolvedCustomPrompt = await resolvePromptInput(customPrompt, "system prompt");
const resolvedAppendPrompt = await resolvePromptInput(appendSystemPrompt, "append system prompt");
// Load SYSTEM.md customization (prepended to prompt)
const systemPromptCustomization = await loadSystemPromptFiles({ cwd: resolvedCwd });
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
// Resolve context files: use provided or discover
const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
// Build tool descriptions array
// Priority: toolNames (explicit list) > tools (Map) > defaults
// Default includes both bash and python; actual availability determined by settings in createTools
const defaultToolNames: ToolName[] = ["read", "bash", "python", "edit", "write"];
let toolNamesArray: string[];
if (toolNames !== undefined) {
// Explicit toolNames list provided (could be empty)
toolNamesArray = toolNames;
} else if (tools !== undefined) {
// Tools map provided
toolNamesArray = Array.from(tools.keys());
} else {
// Use defaults
toolNamesArray = defaultToolNames;
}
const toolDescriptionsArray = toolNamesArray.map((name) => ({
name,
description: toolDescriptions[name as ToolName] ?? "",
}));
// Resolve skills: use provided or discover
const skills =
providedSkills ??
(skillsSettings?.enabled !== false ? (await loadSkills({ ...skillsSettings, cwd: resolvedCwd })).skills : []);
// Get git context
const git = await loadGitContext(resolvedCwd);
// Filter skills to only include those with read tool
const hasRead = tools?.has("read");
const filteredSkills = hasRead ? skills : [];
if (resolvedCustomPrompt) {
return renderPromptTemplate(customSystemPromptTemplate, {
systemPromptCustomization: systemPromptCustomization ?? "",
customPrompt: resolvedCustomPrompt,
appendPrompt: resolvedAppendPrompt ?? "",
contextFiles,
agentsMdSearch,
toolDescriptions: toolDescriptionsArray,
git,
skills: filteredSkills,
rules: rules ?? [],
dateTime,
cwd: resolvedCwd,
});
}
return renderPromptTemplate(systemPromptTemplate, {
tools: toolNamesArray,
toolDescriptions: toolDescriptionsArray,
environment: await getEnvironmentInfo(),
systemPromptCustomization: systemPromptCustomization ?? "",
contextFiles,
agentsMdSearch,
git,
skills: filteredSkills,
rules: rules ?? [],
dateTime,
cwd: resolvedCwd,
appendSystemPrompt: resolvedAppendPrompt ?? "",
});
}