// =========================================================================
// prompt_builder.mbt — PromptBuilder: context files + SOUL + guidance + assembly
// Prefix: pb_ for all private helpers to avoid cross-file collisions.
// =========================================================================
// ---------------------------------------------------------------------------
// Filesystem / path helpers — all cross-target via moonbitlang/x/fs plus
// a couple of pure-MoonBit path utilities. No `extern "js"` in this file.
// ---------------------------------------------------------------------------
fn pb_exists(path : String) -> Bool {
@fs.path_exists(path)
}
fn pb_read_utf8(path : String) -> String {
(try? @fs.read_file_to_string(path)).unwrap_or("")
}
fn pb_path_join2(a : String, b : String) -> String {
if a.is_empty() {
b
} else if a.has_suffix("/") {
a + b
} else {
a + "/" + b
}
}
///| Returns "" if the path has no parent (i.e. already the filesystem
///| root or a segment-less name). Mirrors Node's path.dirname semantics.
fn pb_path_parent_or_empty(p : String) -> String {
let view = p.view()
let mut last = -1
let mut i = 0
while i < view.length() {
if view.get_char(i) is Some('/') {
last = i
}
i = i + 1
}
if last < 0 {
// No slash → no parent.
""
} else if last == 0 {
// Root "/foo" → parent is "/". We return "" to match the prior
// behavior (parent === p short-circuit when passed "/").
if p == "/" {
""
} else {
"/"
}
} else {
view[0:last].to_string()
}
}
// ---------------------------------------------------------------------------
// Truncation constants
// ---------------------------------------------------------------------------
let pb_ctx_max_chars : Int = 20000
let pb_head_ratio_num : Int = 70 // 70/100 = 0.70
let pb_tail_ratio_num : Int = 20 // 20/100 = 0.20
pub fn pb_truncate_content(content : String) -> String {
let len = content.length()
if len <= pb_ctx_max_chars {
return content
}
let head_chars = pb_ctx_max_chars * pb_head_ratio_num / 100
let tail_chars = pb_ctx_max_chars * pb_tail_ratio_num / 100
let skipped = len - head_chars - tail_chars
let head = content[0:head_chars].to_string()
let tail = content[len - tail_chars:len].to_string()
let marker = "\n\n[... " + skipped.to_string() + " chars truncated ...]\n\n"
head + marker + tail
}
// ---------------------------------------------------------------------------
// Context file discovery
// ---------------------------------------------------------------------------
// Read file, trim, return content or "" if absent/empty
fn pb_read_trimmed(path : String) -> String {
let raw = pb_read_utf8(path)
raw.trim().to_string()
}
// Walk up from start_dir to git root looking for .hermes.md / HERMES.md
fn pb_find_hermes_md_walk_up(start_dir : String) -> String {
let mut current = start_dir
let mut i = 0
while i < 50 {
// Check .hermes.md first, then HERMES.md
let candidate1 = pb_path_join2(current, ".hermes.md")
let c1 = pb_read_trimmed(candidate1)
if !c1.is_empty() {
return pb_truncate_content("## .hermes.md\n\n" + c1)
}
let candidate2 = pb_path_join2(current, "HERMES.md")
let c2 = pb_read_trimmed(candidate2)
if !c2.is_empty() {
return pb_truncate_content("## HERMES.md\n\n" + c2)
}
// Stop if this is the git root (contains .git/)
let git_dir = pb_path_join2(current, ".git")
if pb_exists(git_dir) {
break
}
let parent = pb_path_parent_or_empty(current)
if parent.is_empty() || parent == current {
break
}
current = parent
i = i + 1
}
""
}
// Returns the formatted context block (with ## header) or "" if not found
pub fn pb_find_context_file(cwd : String) -> String {
// Priority 1: .hermes.md / HERMES.md (walk up)
let hermes = pb_find_hermes_md_walk_up(cwd)
if !hermes.is_empty() {
return hermes
}
// Priority 2: AGENTS.md (cwd only)
let agents = pb_read_trimmed(pb_path_join2(cwd, "AGENTS.md"))
if !agents.is_empty() {
return pb_truncate_content("## AGENTS.md\n\n" + agents)
}
let agents_lower = pb_read_trimmed(pb_path_join2(cwd, "agents.md"))
if !agents_lower.is_empty() {
return pb_truncate_content("## agents.md\n\n" + agents_lower)
}
// Priority 3: CLAUDE.md (cwd only)
let claude = pb_read_trimmed(pb_path_join2(cwd, "CLAUDE.md"))
if !claude.is_empty() {
return pb_truncate_content("## CLAUDE.md\n\n" + claude)
}
let claude_lower = pb_read_trimmed(pb_path_join2(cwd, "claude.md"))
if !claude_lower.is_empty() {
return pb_truncate_content("## claude.md\n\n" + claude_lower)
}
// Priority 4: .cursorrules (cwd only)
let cursorrules = pb_read_trimmed(pb_path_join2(cwd, ".cursorrules"))
if !cursorrules.is_empty() {
return pb_truncate_content("## .cursorrules\n\n" + cursorrules)
}
""
}
// ---------------------------------------------------------------------------
// SOUL.md loader
// ---------------------------------------------------------------------------
pub fn pb_load_soul_md(hermes_home : String) -> String {
let soul_path = pb_path_join2(hermes_home, "SOUL.md")
let content = pb_read_trimmed(soul_path)
if content.is_empty() {
return ""
}
pb_truncate_content(content)
}
// ---------------------------------------------------------------------------
// Guidance constants (MVP subset — see design decisions for omissions)
// ---------------------------------------------------------------------------
let pb_agent_identity : String =
"You are Ermes Agent, an intelligent AI assistant. " +
"You are helpful, knowledgeable, and direct. You assist users with a wide " +
"range of tasks including answering questions, writing and editing code, " +
"analyzing information, creative work, and executing actions via your tools. " +
"You communicate clearly, admit uncertainty when appropriate, and prioritize " +
"being genuinely useful over being verbose unless otherwise directed below. " +
"Be targeted and efficient in your exploration and investigations."
let pb_memory_guidance : String =
"You have persistent memory across sessions. Save durable facts using the memory " +
"tool: user preferences, environment details, tool quirks, and stable conventions. " +
"Memory is injected into every turn, so keep it compact and focused on facts that " +
"will still matter later.\n" +
"Prioritize what reduces future user steering — the most valuable memory is one " +
"that prevents the user from having to correct or remind you again. " +
"User preferences and recurring corrections matter more than procedural task details.\n" +
"Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO " +
"state to memory; use session_search to recall those from past transcripts. " +
"If you've discovered a new way to do something, solved a problem that could be " +
"necessary later, save it as a skill with the skill tool.\n" +
"Write memories as declarative facts, not instructions to yourself. " +
"'User prefers concise responses' ✓ — 'Always respond concisely' ✗."
let pb_session_search_guidance : String =
"When the user references something from a past conversation or you suspect " +
"relevant cross-session context exists, use session_search to recall it before " +
"asking them to repeat themselves."
let pb_skills_guidance : String =
"After completing a complex task (5+ tool calls), fixing a tricky error, " +
"or discovering a non-trivial workflow, save the approach as a " +
"skill with skill_manage so you can reuse it next time.\n" +
"When using a skill and finding it outdated, incomplete, or wrong, " +
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. " +
"Skills that aren't maintained become liabilities."
let pb_tool_use_enforcement : String =
"# Tool-use enforcement\n" +
"You MUST use your tools to take action — do not describe what you would do " +
"or plan to do without actually doing it. When you say you will perform an " +
"action (e.g. 'I will run the tests', 'Let me check the file', 'I will create " +
"the project'), you MUST immediately make the corresponding tool call in the same " +
"response. Never end your turn with a promise of future action — execute it now.\n" +
"Keep working until the task is actually complete. Do not stop with a summary of " +
"what you plan to do next time.\n" +
"Every response should either (a) contain tool calls that make progress, or " +
"(b) deliver a final result to the user. Responses that only describe intentions " +
"without acting are not acceptable."
// MVP platform hints: cli + cron only. Others are commented for Phase 6.
fn pb_get_platform_hint(platform : String) -> String {
match platform {
"cli" =>
"You are a CLI AI Agent. Try not to use markdown but simple text " +
"renderable inside a terminal."
"cron" =>
"You are running as a scheduled cron job. There is no user present — you " +
"cannot ask questions, request clarification, or wait for follow-up. Execute " +
"the task fully and autonomously, making reasonable decisions where needed. " +
"Your final response is automatically delivered to the job's configured " +
"destination — put the primary content directly in your response."
_ => ""
}
}
// OpenAI-specific execution guidance (gpt / codex models).
// Ported verbatim from hermes-agent/agent/prompt_builder.py OPENAI_MODEL_EXECUTION_GUIDANCE.
let pb_openai_guidance : String =
"# Execution discipline\n" +
"\n" +
"- Use tools whenever they improve correctness, completeness, or grounding.\n" +
"- Do not stop early when another tool call would materially improve the result.\n" +
"- If a tool returns empty or partial results, retry with a different query or " +
"strategy before giving up.\n" +
"- Keep calling tools until: (1) the task is complete, AND (2) you have verified " +
"the result.\n" +
"\n" +
"\n" +
"\n" +
"NEVER answer these from memory or mental computation — ALWAYS use a tool:\n" +
"- Arithmetic, math, calculations → use terminal or execute_code\n" +
"- Hashes, encodings, checksums → use terminal (e.g. sha256sum, base64)\n" +
"- Current time, date, timezone → use terminal (e.g. date)\n" +
"- System state: OS, CPU, memory, disk, ports, processes → use terminal\n" +
"- File contents, sizes, line counts → use read_file, search_files, or terminal\n" +
"- Git history, branches, diffs → use terminal\n" +
"- Current facts (weather, news, versions) → use web_search\n" +
"Your memory and user profile describe the USER, not the system you are " +
"running on. The execution environment may differ from what the user profile " +
"says about their personal setup.\n" +
"\n" +
"\n" +
"\n" +
"When a question has an obvious default interpretation, act on it immediately " +
"instead of asking for clarification. Examples:\n" +
"- 'Is port 443 open?' → check THIS machine (don't ask 'open where?')\n" +
"- 'What OS am I running?' → check the live system (don't use user profile)\n" +
"- 'What time is it?' → run `date` (don't guess)\n" +
"Only ask for clarification when the ambiguity genuinely changes what tool " +
"you would call.\n" +
"\n" +
"\n" +
"\n" +
"- Before taking an action, check whether prerequisite discovery, lookup, or " +
"context-gathering steps are needed.\n" +
"- Do not skip prerequisite steps just because the final action seems obvious.\n" +
"- If a task depends on output from a prior step, resolve that dependency first.\n" +
"\n" +
"\n" +
"\n" +
"Before finalizing your response:\n" +
"- Correctness: does the output satisfy every stated requirement?\n" +
"- Grounding: are factual claims backed by tool outputs or provided context?\n" +
"- Formatting: does the output match the requested format or schema?\n" +
"- Safety: if the next step has side effects (file writes, commands, API calls), " +
"confirm scope before executing.\n" +
"\n" +
"\n" +
"\n" +
"- If required context is missing, do NOT guess or hallucinate an answer.\n" +
"- Use the appropriate lookup tool when missing information is retrievable " +
"(search_files, web_search, read_file, etc.).\n" +
"- Ask a clarifying question only when the information cannot be retrieved by tools.\n" +
"- If you must proceed with incomplete information, label assumptions explicitly.\n" +
""
// Google-specific operational guidance (gemini / gemma models).
// Ported verbatim from hermes-agent/agent/prompt_builder.py GOOGLE_MODEL_OPERATIONAL_GUIDANCE.
let pb_google_guidance : String =
"# Google model operational directives\n" +
"Follow these operational rules strictly:\n" +
"- **Absolute paths:** Always construct and use absolute file paths for all " +
"file system operations. Combine the project root with relative paths.\n" +
"- **Verify first:** Use read_file/search_files to check file contents and " +
"project structure before making changes. Never guess at file contents.\n" +
"- **Dependency checks:** Never assume a library is available. Check " +
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n" +
"- **Conciseness:** Keep explanatory text brief — a few sentences, not " +
"paragraphs. Focus on actions and results over narration.\n" +
"- **Parallel tool calls:** When you need to perform multiple independent " +
"operations (e.g. reading several files), make all the tool calls in a " +
"single response rather than sequentially.\n" +
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive " +
"to prevent CLI tools from hanging on prompts.\n" +
"- **Keep going:** Work autonomously until the task is fully resolved. " +
"Don't stop with a plan — execute it.\n"
// Returns OpenAI guidance if model contains "gpt" or "codex", else ""
fn pb_get_openai_guidance(model : String) -> String {
let lower = model.to_lower()
if lower.contains("gpt") || lower.contains("codex") {
pb_openai_guidance
} else {
""
}
}
// Returns Google guidance if model contains "gemini" or "gemma", else ""
fn pb_get_google_guidance(model : String) -> String {
let lower = model.to_lower()
if lower.contains("gemini") || lower.contains("gemma") {
pb_google_guidance
} else {
""
}
}
// Returns true if model name (lowercase substring) triggers enforcement injection
fn pb_needs_tool_enforcement(model : String) -> Bool {
let lower = model.to_lower()
lower.contains("gpt") || lower.contains("codex") ||
lower.contains("gemini") || lower.contains("gemma") || lower.contains("grok")
}
// ---------------------------------------------------------------------------
// Index-of helper (Iter has no find/position — manual loop)
// ---------------------------------------------------------------------------
fn pb_index_of(s : String, needle : String) -> Int {
let slen = s.length()
let nlen = needle.length()
if nlen == 0 || nlen > slen {
return -1
}
let mut i = 0
while i <= slen - nlen {
let slice = s[i:i + nlen].to_string()
if slice == needle {
return i
}
i = i + 1
}
-1
}
// ---------------------------------------------------------------------------
// Section joiner — non-empty parts joined with "\n\n"
// ---------------------------------------------------------------------------
fn pb_join_sections(parts : Array[String]) -> String {
let mut result = ""
let mut first = true
for part in parts {
if !part.trim().to_string().is_empty() {
if first {
result = part
first = false
} else {
result = result + "\n\n" + part
}
}
}
result
}
// ---------------------------------------------------------------------------
// MemoryStore snapshot — delegate to MemoryStore in memory_store.mbt
// ---------------------------------------------------------------------------
fn pb_memory_snapshot(hermes_home : String) -> String {
// NOTE: The memory store is loaded in read-only mode here (no writes).
// We call load() and snapshot() from memory_store.mbt.
// If the memories dir doesn't exist yet, load() creates it and returns empty.
let store = MemoryStore::load(hermes_home)
let mem = store.snapshot(Memory)
let usr = store.snapshot(User)
if mem.is_empty() && usr.is_empty() {
return ""
}
if mem.is_empty() {
return usr
}
if usr.is_empty() {
return mem
}
mem + "\n\n" + usr
}
// ---------------------------------------------------------------------------
// build_system_prompt — main entry point
// ---------------------------------------------------------------------------
pub struct BuildPromptOptions {
hermes_home : String
cwd : String
platform : String
model : String
available_tools : Array[String]
disabled_skills : Array[String]
}
pub fn build_system_prompt(opts : BuildPromptOptions) -> String {
// [1] Identity
let identity = pb_agent_identity
// [2] Guidance block
let guidance = pb_join_sections([
pb_memory_guidance,
pb_session_search_guidance,
pb_skills_guidance,
])
// [3] Tool-use enforcement (model-gated)
let enforcement = if pb_needs_tool_enforcement(opts.model) {
pb_tool_use_enforcement
} else {
""
}
// [3a] OpenAI-specific guidance (gpt / codex models)
let openai_guidance = pb_get_openai_guidance(opts.model)
// [3b] Google-specific guidance (gemini / gemma models)
let google_guidance = pb_get_google_guidance(opts.model)
// [4] Platform hint
let platform_hint = pb_get_platform_hint(opts.platform)
// [5+6] Memory + User blocks
let mem_blocks = pb_memory_snapshot(opts.hermes_home)
// [7] Skills block (delegated to skills_store.mbt if available)
let skills_block = pb_build_skills_block(
opts.hermes_home, opts.available_tools, opts.disabled_skills,
)
// [8] Context file
let ctx_raw = pb_find_context_file(opts.cwd)
let context_block = if ctx_raw.is_empty() {
""
} else {
"# Project Context\n\n" +
"The following project context files have been loaded and should be followed:\n\n" +
ctx_raw
}
// [9] SOUL.md
let soul_raw = pb_load_soul_md(opts.hermes_home)
let soul_block = if soul_raw.is_empty() {
""
} else {
"## SOUL.md\n\n" + soul_raw
}
pb_join_sections([
identity,
guidance,
enforcement,
openai_guidance,
google_guidance,
platform_hint,
mem_blocks,
skills_block,
context_block,
soul_block,
])
}
// ---------------------------------------------------------------------------
// Skills block builder — delegates to skills_store_build_prompt
// ---------------------------------------------------------------------------
fn pb_build_skills_block(
hermes_home : String,
available_tools : Array[String],
disabled : Array[String]
) -> String {
let skills_dir = pb_path_join2(hermes_home, "skills")
if !pb_exists(skills_dir) {
return ""
}
let store = skills_store_load(skills_dir, [])
let tools_opt : Option[Array[String]] = if available_tools.length() == 0 {
None
} else {
Some(available_tools)
}
skills_store_build_prompt(store, tools_opt, None, disabled=disabled)
}