Files
oh-my-pi/packages/coding-agent/src/system-prompt.ts
T
can1357 6b4accd05e feat(memory): added memory_edit tool and stats/diagnose commands
- Added `memory_edit` tool for update, forget, and invalidate operations on Mnemosyne memories by id.
- Added `stats` and `diagnose` methods to `MemoryBackend` interface with Mnemosyne implementations.
- Exposed `/memory stats` and `/memory diagnose` slash commands in TUI and ACP modes.
- Refactored recall output to include memory ids via `formatScopedRecallWithIds`.
2026-05-30 16:48:02 +02:00

588 lines
20 KiB
TypeScript

/**
* System prompt construction and project context loading
*/
import * as os from "node:os";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { contextFileCapability } from "./capability/context-file";
import { systemPromptCapability } from "./capability/system-prompt";
import type { SkillsSettings } from "./config/settings";
import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
import { loadSkills, type Skill } from "./extensibility/skills";
import { hasObsidian } from "./internal-urls/vault-protocol";
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.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 { shortenPath } from "./tools/render-utils";
import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
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<string | null | undefined>,
): 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 | null | undefined>): 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 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;
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 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 system info structure */
interface GpuCache {
gpu: string;
}
function getSystemInfoCachePath(): string {
return getGpuCachePath();
}
async function loadGpuCache(): Promise<GpuCache | null> {
try {
const cachePath = getSystemInfoCachePath();
const content = await Bun.file(cachePath).json();
return content as GpuCache;
} catch {
return null;
}
}
async function saveGpuCache(info: GpuCache): Promise<void> {
try {
const cachePath = getSystemInfoCachePath();
await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
} catch {
// Silently ignore cache write failures
}
}
async function getCachedGpu(): Promise<string | undefined> {
const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache);
if (cached) return cached.gpu;
const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel);
if (gpu) {
await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
}
return gpu ?? undefined;
}
async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
const gpu = await getCachedGpu();
let cpuModel: string | undefined;
try {
cpuModel = os.cpus()[0]?.model;
} catch {
cpuModel = undefined;
}
const entries: Array<{ label: string; value: string | undefined }> = [
{ label: "OS", value: `${os.platform()} ${os.release()}` },
{ label: "Distro", value: os.type() },
{ label: "Kernel", value: os.version() },
{ 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);
}
/** Resolve input as file path or literal string */
export async function resolvePromptInput(input: string | undefined, description: string): Promise<string | undefined> {
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;
}
function dedupeExactContextFiles(
contextFiles: Array<{ path: string; content: string; depth?: number }>,
): Array<{ path: string; content: string; depth?: number }> {
const lastIndexByContent = new Map<string, number>();
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<Array<{ path: string; content: string; depth?: number }>> {
const resolvedCwd = options.cwd ?? getProjectDir();
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 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<string | null> {
const resolvedCwd = options.cwd ?? getProjectDir();
const result = await loadCapability<SystemPromptFile>(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 interface SystemPromptToolMetadata {
label: string;
description: string;
/** Tool name the model sees on the provider wire. Defaults to the internal tool name. */
wireName?: string;
}
export function buildSystemPromptToolMetadata(
tools: Map<string, AgentTool>,
overrides: Partial<Record<string, Partial<SystemPromptToolMetadata>>> = {},
): Map<string, SystemPromptToolMetadata> {
return new Map(
Array.from(tools.entries(), ([name, tool]) => {
const toolRecord = tool as AgentTool & { label?: string; description?: string };
const override = overrides[name];
const wireName =
override?.wireName ??
(typeof toolRecord.customWireName === "string" ? toolRecord.customWireName : undefined);
return [
name,
{
label: override?.label ?? (typeof toolRecord.label === "string" ? toolRecord.label : ""),
description:
override?.description ?? (typeof toolRecord.description === "string" ? toolRecord.description : ""),
wireName,
},
] as const;
}),
);
}
export interface BuildSystemPromptOptions {
/** Custom system prompt (replaces default). */
customPrompt?: string;
/** Tools to include in prompt. */
tools?: Map<string, SystemPromptToolMetadata>;
/** Tool names to include in prompt. */
toolNames?: string[];
/** Text to append to system prompt. */
appendSystemPrompt?: string;
/** Repeat full tool descriptions in system prompt. Default: false */
repeatToolDescriptions?: boolean;
/** Skills settings for discovery. */
skillsSettings?: SkillsSettings;
/** Working directory. Default: getProjectDir() */
cwd?: 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?: 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;
/** Whether MCP tool discovery is active for this prompt build. */
mcpDiscoveryMode?: boolean;
/** Discoverable MCP server summaries to advertise when discovery mode is active. */
mcpDiscoveryServerSummaries?: string[];
/** Encourage the agent to delegate via tasks unless changes are trivial. */
eagerTasks?: 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<WorkspaceTree>;
/** Whether the local memory://root summary is active. */
memoryRootEnabled?: 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[];
}
/** Build the system prompt with tools, guidelines, and context */
export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<BuildSystemPromptResult> {
if ($env.NULL_PROMPT === "true") {
return { systemPrompt: [] };
}
const {
customPrompt,
tools,
appendSystemPrompt,
repeatToolDescriptions = false,
skillsSettings,
toolNames: providedToolNames,
cwd,
contextFiles: providedContextFiles,
skills: providedSkills,
rules,
alwaysApplyRules,
intentField,
mcpDiscoveryMode = false,
mcpDiscoveryServerSummaries = [],
eagerTasks = false,
secretsEnabled = false,
workspaceTree: providedWorkspaceTree,
memoryRootEnabled = false,
} = options;
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,
};
const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
const timedOut: string[] = [];
const failed: Array<{ name: string; error: unknown }> = [];
async function withDeadline<T>(name: string, work: Promise<T>, fallback: T): Promise<T> {
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;
}
const systemPromptCustomizationPromise = logger.time("loadSystemPromptFiles", loadSystemPromptFiles, {
cwd: resolvedCwd,
});
const contextFilesPromise = providedContextFiles
? Promise.resolve(providedContextFiles)
: logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd });
const workspaceTreePromise =
providedWorkspaceTree !== undefined
? Promise.resolve(providedWorkspaceTree)
: logger.time("buildWorkspaceTree", () =>
buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }),
);
const skillsPromise: Promise<Skill[]> =
providedSkills !== undefined
? Promise.resolve(providedSkills)
: skillsSettings?.enabled !== false
? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
: Promise.resolve([]);
const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] =
await Promise.all([
withDeadline(
"customPrompt",
resolvePromptInput(customPrompt, "system prompt"),
prepDefaults.resolvedCustomPrompt,
),
withDeadline(
"appendSystemPrompt",
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),
]);
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 = new Date().toISOString().slice(0, 10);
const dateTime = date;
const promptCwd = shortenPath(resolvedCwd.replace(/\\/g, "/"));
// Build tool metadata for system prompt rendering
// Priority: explicit list > tools map > defaults
// Default includes both bash and python; actual availability determined by settings in createTools
let toolNames = providedToolNames;
if (!toolNames) {
if (tools) {
// Tools map provided
toolNames = Array.from(tools.keys());
} else {
// Use defaults
toolNames = ["read", "bash", "eval", "edit", "write"]; // TODO: Why?
}
}
// Build tool descriptions for system prompt rendering.
const toolPromptNames = new Map<string, string>(toolNames.map(name => [name, tools?.get(name)?.wireName ?? name]));
const toolRefs = Object.fromEntries(toolPromptNames.entries());
const toolInfo = toolNames.map(name => ({
name: toolPromptNames.get(name) ?? name,
internalName: name,
label: tools?.get(name)?.label ?? "",
description: tools?.get(name)?.description ?? "",
}));
// 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:<name>).
const hasRead = tools?.has("read");
const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
resolvedCustomPrompt,
resolvedAppendPrompt,
]);
const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
const data = {
systemPromptCustomization: effectiveSystemPromptCustomization,
customPrompt: resolvedCustomPrompt,
appendPrompt: resolvedAppendPrompt ?? "",
tools: toolNames,
toolInfo,
repeatToolDescriptions,
toolRefs,
environment,
contextFiles,
agentsMdSearch: { files: agentsMdFiles },
workspaceTree,
skills: filteredSkills,
rules: rules ?? [],
alwaysApplyRules: injectedAlwaysApplyRules,
date,
dateTime,
cwd: promptCwd,
intentTracing: !!intentField,
intentField: intentField ?? "",
mcpDiscoveryMode,
hasMCPDiscoveryServers: mcpDiscoveryServerSummaries.length > 0,
mcpDiscoveryServerSummaries,
eagerTasks,
secretsEnabled,
hasMemoryRoot: memoryRootEnabled,
hasObsidian: hasObsidian(),
};
const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
const systemPrompt = [rendered];
const projectPrompt = resolvedCustomPrompt ? "" : prompt.render(projectPromptTemplate, data).trim();
if (projectPrompt) {
systemPrompt.push(projectPrompt);
}
return { systemPrompt };
}