use std::path::{Path, PathBuf};
use crate::account::{get_accounts_path, AccountStore};
use crate::project::Project;
const IDENTITY: &str = "\
You are Procyon, a terminal development harness for Stellar and Soroban smart contracts. You \
work inside the user's workspace through the tools you are given.";
const WORKING_RULES: &str = "\
## Working rules
- Locate code with `glob` and `grep` before reading it. Do not guess file paths.
- When the answer depends on how Stellar or Soroban actually behaves — storage and TTL, auth, \
SEPs, CLI flags, fees, XDR — or on explaining a contract or host error, look it up with a \
connected MCP tool and cite what you found. Prefer looking it up over recalling from memory, \
and say so plainly when no source is available rather than guessing.
- Every file tool is confined to the workspace; paths outside it are rejected, so do not try \
absolute paths elsewhere.
- `write_file` and `edit_file` change the user's files immediately and are not undoable by you. \
Read a file before editing it, and prefer `edit_file` over rewriting a whole file.
- In a project that has `caatinga.config.ts`, use the `caatinga_*` tools for build, deploy and \
invoke rather than raw `stellar` CLI commands. They take contract *names* from that config, never \
a wasm path or a contract id: Caatinga resolves those from `caatinga.artifacts.json`, deploys in \
dependency order, and regenerates bindings afterwards. Do not copy a contract id out of a deploy \
log — read it from the artifacts, or let the tool report it. In a project without that config the \
`caatinga_*` tools will refuse, and the `stellar` CLI is the right path.
- Signing is always by Stellar CLI identity alias, such as `alice`. Never pass a secret key, seed \
phrase or raw address as `source`: it would reach the process list and the session log.
- Any tool that signs and submits requires the network as an explicit argument; there is no \
default. State the network you are about to act on before calling it.
- Mainnet is refused unless the operator enabled it in the config or the environment. You cannot \
enable it — if a mainnet operation is wanted, say what the user has to set and stop. Prefer \
`caatinga_read` over `caatinga_invoke` whenever you only need to read a value: it simulates, so it \
signs nothing and costs nothing.
- When a tool fails, read its error before retrying. Do not repeat an identical failing call.";
const RECENT_OPERATIONS: usize = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct Operation {
pub tool: String,
pub ok: bool,
pub reason: Option<String>,
}
#[derive(Debug, Default)]
pub struct OperationLog {
entries: std::collections::VecDeque<Operation>,
}
impl OperationLog {
pub fn record(&mut self, tool: &str, outcome: Result<(), &str>) {
if self.entries.len() == RECENT_OPERATIONS {
self.entries.pop_front();
}
self.entries.push_back(Operation {
tool: tool.to_string(),
ok: outcome.is_ok(),
reason: outcome.err().map(first_line),
});
}
pub fn recent(&self) -> Vec<Operation> {
self.entries.iter().cloned().collect()
}
}
fn first_line(text: &str) -> String {
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(text);
let line = line.trim();
match line.char_indices().nth(160) {
Some((cut, _)) => format!("{}…", &line[..cut]),
None => line.to_string(),
}
}
async fn has_caatinga_config(dir: &Path) -> bool {
for name in ["caatinga.config.ts", "caatinga.config.js"] {
if tokio::fs::try_exists(dir.join(name)).await.unwrap_or(false) {
return true;
}
}
false
}
pub struct WorkspaceContext {
pub cwd: PathBuf,
pub project: Option<Project>,
pub accounts: Vec<String>,
pub stellar_cli: Option<String>,
pub npx: bool,
pub mcp_servers: Vec<String>,
pub skills: Vec<String>,
pub operations: Vec<Operation>,
pub unverified: Vec<String>,
pub caatinga_config: bool,
}
impl WorkspaceContext {
pub async fn gather(cwd: &Path, mcp_servers: &[String]) -> Self {
let discovered = Project::discover(cwd).await;
let project_dir = discovered.as_ref().map(|d| d.dir.clone());
let project = discovered.map(|d| d.project);
let accounts = match &project_dir {
Some(dir) => AccountStore::load(&get_accounts_path(dir))
.await
.map(|store| {
store
.list()
.iter()
.map(|a| format!("{} ({}) [{}]", a.name, a.address, a.network))
.collect()
})
.unwrap_or_default(),
None => Vec::new(),
};
Self {
cwd: cwd.to_path_buf(),
project,
accounts,
stellar_cli: stellar_cli_version().await,
npx: which::which("npx").is_ok(),
mcp_servers: mcp_servers.to_vec(),
skills: Vec::new(),
operations: Vec::new(),
unverified: Vec::new(),
caatinga_config: has_caatinga_config(cwd).await,
}
}
pub fn with_session(mut self, skills: Vec<String>, operations: Vec<Operation>) -> Self {
self.skills = skills;
self.operations = operations;
self
}
pub fn with_unverified(mut self, unverified: Vec<String>) -> Self {
self.unverified = unverified;
self
}
pub fn system_prompt(&self) -> String {
let mut out = String::from(IDENTITY);
out.push_str("\n\n## Environment\n\n");
out.push_str(&format!("- Workspace: {}\n", self.cwd.display()));
match &self.project {
Some(project) => {
out.push_str(&format!(
"- Project: {} v{}\n- Default network: {}\n",
project.name, project.version, project.default_network
));
if project.contracts.is_empty() {
out.push_str("- Contracts: none registered yet\n");
} else {
out.push_str("- Contracts:\n");
for contract in &project.contracts {
out.push_str(&format!(
" - {} ({})\n",
contract.name,
contract.address.as_deref().unwrap_or("not deployed")
));
}
}
if project.is_inferred() {
out.push_str(
"- Project state: read from the repository itself; there is no \
`.procyon/project.toml`. Tools that need project state will say so — run \
`project_init` only if the user asks for it.\n",
);
}
}
None => out.push_str(
"- Project: no .procyon/project.toml found. Use `project_init` before tools that \
need project state.\n",
),
}
out.push_str(if self.caatinga_config {
"- Caatinga: caatinga.config.ts at the workspace root — the `caatinga_*` tools apply.\n"
} else {
"- Caatinga: no caatinga.config.ts at the workspace root; the `caatinga_*` tools will \
refuse. Use the `stellar` CLI tools instead.\n"
});
if self.accounts.is_empty() {
out.push_str("- Accounts: none configured\n");
} else {
out.push_str("- Accounts:\n");
for account in &self.accounts {
out.push_str(&format!(" - {}\n", account));
}
}
out.push_str(&format!(
"- stellar CLI: {}\n- npx: {}\n",
self.stellar_cli.as_deref().unwrap_or("not installed"),
if self.npx {
"available"
} else {
"not installed"
}
));
if self.mcp_servers.is_empty() {
out.push_str(
"- MCP servers: none connected, so you have no way to look up Stellar facts\n",
);
} else {
out.push_str("- MCP servers:\n");
for server in &self.mcp_servers {
out.push_str(&format!(" - {}\n", server));
}
}
if self.skills.is_empty() {
out.push_str("- Skills: none installed\n");
} else {
out.push_str(&format!(
"- Skills (load with `run_skill`): {}\n",
self.skills.join(", ")
));
}
out.push_str(
"- Specialists (reach with `talk_to`): SorobanArchitect (contract and project \
design, read-only), ContractDebugger (build/simulation/auth failures, may build and \
test), StellarTransactionExpert (XDR, fees, signatures, networks, read-only), \
SecurityAuditor (contract and permission review, read-only), FrontendIntegrator \
(bindings, wallets, client SDKs, may write files), DeploymentEngineer (build, deploy, \
invoke — the only one that may sign). Prefer delegating to the specialist whose \
description matches the question over answering it yourself, especially for a \
security review or a transaction-level question.\n",
);
if !self.unverified.is_empty() {
out.push_str("\n## Written but never compiled\n\n");
for path in &self.unverified {
out.push_str(&format!("- {}\n", path));
}
out.push_str(
"\nNo build or test run has covered these changes. Run `caatinga_build` (or \
`run_tests`) and read the result before describing this work as done. \
`caatinga_deploy` will refuse until then: it ships the wasm from the last build, \
so deploying now would put the previous version of the contract on chain.\n",
);
}
if !self.operations.is_empty() {
out.push_str("\n## Recent operations, oldest first\n\n");
for operation in &self.operations {
match (&operation.reason, operation.ok) {
(Some(reason), _) => {
out.push_str(&format!("- {} — failed: {}\n", operation.tool, reason))
}
(None, true) => out.push_str(&format!("- {} — ok\n", operation.tool)),
(None, false) => out.push_str(&format!("- {} — failed\n", operation.tool)),
}
}
out.push_str(
"\nThese ran earlier in this session and may predate the conversation you can \
see. A call listed as failed failed; do not present it as done, and do not repeat \
it unchanged.\n",
);
}
out.push('\n');
out.push_str(WORKING_RULES);
out
}
}
async fn stellar_cli_version() -> Option<String> {
if which::which("stellar").is_err() {
return None;
}
let output = tokio::process::Command::new("stellar")
.arg("--version")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|line| line.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::{Contract, Network};
fn context() -> WorkspaceContext {
WorkspaceContext {
cwd: PathBuf::from("/w/demo"),
project: None,
accounts: Vec::new(),
stellar_cli: None,
npx: false,
mcp_servers: Vec::new(),
skills: Vec::new(),
operations: Vec::new(),
unverified: Vec::new(),
caatinga_config: false,
}
}
#[test]
fn states_the_identity_and_the_workspace() {
let prompt = context().system_prompt();
assert!(prompt.contains("Stellar and Soroban"));
assert!(prompt.contains("/w/demo"));
}
#[test]
fn says_plainly_when_there_is_no_project() {
let prompt = context().system_prompt();
assert!(prompt.contains("no .procyon/project.toml found"));
assert!(prompt.contains("Accounts: none configured"));
}
#[test]
fn a_project_without_a_config_at_the_root_says_the_caatinga_tools_will_refuse() {
let mut ctx = context();
ctx.project = Some(Project::new("demo"));
let prompt = ctx.system_prompt();
assert!(prompt.contains("Project: demo"), "{}", prompt);
assert!(
prompt.contains("no caatinga.config.ts at the workspace root"),
"{}",
prompt
);
}
#[test]
fn a_config_at_the_root_says_the_caatinga_tools_apply() {
let mut ctx = context();
ctx.caatinga_config = true;
assert!(ctx.system_prompt().contains("the `caatinga_*` tools apply"));
}
#[test]
fn reports_missing_tooling_instead_of_staying_silent() {
let prompt = context().system_prompt();
assert!(prompt.contains("stellar CLI: not installed"));
assert!(prompt.contains("npx: not installed"));
}
#[test]
fn includes_project_network_and_contract_addresses() {
let mut ctx = context();
ctx.project = Some(Project {
name: "demo".to_string(),
version: "0.2.0".to_string(),
default_network: Network::Testnet,
contracts: vec![
Contract {
name: "token".to_string(),
address: Some("CDLZ".to_string()),
wasm_path: None,
},
Contract {
name: "pending".to_string(),
address: None,
wasm_path: None,
},
],
..Project::new("demo")
});
ctx.accounts = vec!["alice (GALICE) [testnet]".to_string()];
let prompt = ctx.system_prompt();
assert!(prompt.contains("demo v0.2.0"));
assert!(prompt.contains("Default network: testnet"));
assert!(prompt.contains("token (CDLZ)"));
assert!(prompt.contains("pending (not deployed)"));
assert!(prompt.contains("alice (GALICE) [testnet]"));
}
#[test]
fn names_the_installed_skills_so_the_model_knows_they_exist() {
let mut ctx = context();
ctx.skills = vec!["soroban".to_string(), "caatinga".to_string()];
let prompt = ctx.system_prompt();
assert!(prompt.contains("run_skill"), "got {}", prompt);
assert!(prompt.contains("soroban, caatinga"), "got {}", prompt);
}
#[test]
fn says_plainly_when_no_skill_is_installed() {
assert!(context().system_prompt().contains("Skills: none installed"));
}
#[test]
fn a_failed_operation_is_carried_with_its_reason() {
let mut ctx = context();
ctx.operations = vec![
Operation {
tool: "caatinga_build".to_string(),
ok: true,
reason: None,
},
Operation {
tool: "caatinga_deploy".to_string(),
ok: false,
reason: Some("no identity named 'alice'".to_string()),
},
];
let prompt = ctx.system_prompt();
assert!(prompt.contains("Recent operations"), "got {}", prompt);
assert!(prompt.contains("caatinga_build — ok"), "got {}", prompt);
assert!(
prompt.contains("caatinga_deploy — failed: no identity named 'alice'"),
"got {}",
prompt
);
assert!(
prompt.contains("do not present it as done"),
"got {}",
prompt
);
}
#[test]
fn code_written_and_never_compiled_is_named_as_such() {
let mut ctx = context();
ctx.unverified = vec!["contracts/counter/src/lib.rs".to_string()];
let prompt = ctx.system_prompt();
assert!(
prompt.contains("Written but never compiled"),
"got {}",
prompt
);
assert!(
prompt.contains("contracts/counter/src/lib.rs"),
"got {}",
prompt
);
assert!(prompt.contains("caatinga_build"), "got {}", prompt);
assert!(prompt.contains("will refuse"), "got {}", prompt);
}
#[test]
fn a_workspace_with_nothing_unbuilt_carries_no_such_section() {
assert!(!context()
.system_prompt()
.contains("Written but never compiled"));
}
#[test]
fn a_session_with_nothing_behind_it_carries_no_empty_section() {
assert!(!context().system_prompt().contains("Recent operations"));
}
#[test]
fn the_ledger_keeps_the_most_recent_operations() {
let mut log = OperationLog::default();
for index in 0..RECENT_OPERATIONS + 3 {
log.record(&format!("tool{}", index), Ok(()));
}
let recent = log.recent();
assert_eq!(recent.len(), RECENT_OPERATIONS);
assert_eq!(
recent.first().unwrap().tool,
"tool3",
"oldest dropped first"
);
assert_eq!(recent.last().unwrap().tool, "tool10");
}
#[test]
fn a_recorded_failure_is_reduced_to_one_line() {
let mut log = OperationLog::default();
log.record(
"run_tests",
Err("error[E0308]: mismatched types\n --> src/lib.rs:12\nand 300 more lines"),
);
let reason = log.recent()[0].reason.clone().unwrap();
assert_eq!(reason, "error[E0308]: mismatched types");
}
#[test]
fn a_long_single_line_failure_is_cut_rather_than_carried_whole() {
let mut log = OperationLog::default();
log.record("stellar_invoke", Err(&"x".repeat(500)));
let reason = log.recent()[0].reason.clone().unwrap();
assert!(
reason.chars().count() <= 161,
"got {} chars",
reason.chars().count()
);
assert!(reason.ends_with('…'));
}
#[test]
fn says_plainly_when_no_mcp_server_is_connected() {
let prompt = context().system_prompt();
assert!(prompt.contains("none connected"), "got {}", prompt);
}
#[test]
fn lists_connected_mcp_servers_and_their_tools() {
let mut ctx = context();
ctx.mcp_servers = vec!["raven (https://raven.stellar.org/mcp): raven__search".to_string()];
let prompt = ctx.system_prompt();
assert!(prompt.contains("raven__search"), "got {}", prompt);
assert!(prompt.contains("https://raven.stellar.org/mcp"));
}
#[test]
fn directs_the_model_to_look_up_rather_than_recall() {
let prompt = context().system_prompt();
assert!(prompt.contains("Prefer looking it up over recalling from memory"));
assert!(prompt.contains("host error"));
}
#[test]
fn tells_the_model_to_search_before_reading() {
let prompt = context().system_prompt();
assert!(prompt.contains("`glob`"));
assert!(prompt.contains("`grep`"));
assert!(prompt.contains("Do not guess file paths"));
}
#[test]
fn warns_about_mainnet_and_unrecoverable_writes() {
let prompt = context().system_prompt();
assert!(prompt.contains("mainnet"));
assert!(prompt.contains("not undoable"));
}
#[test]
fn an_inferred_project_is_described_but_flagged_as_having_no_state() {
let mut ctx = context();
ctx.project = Some(Project {
name: "my-app".to_string(),
source: crate::project::ProjectSource::Inferred,
..Project::new("my-app")
});
let prompt = ctx.system_prompt();
assert!(prompt.contains("my-app v0.1.0"));
assert!(
prompt.contains("read from the repository itself"),
"{}",
prompt
);
assert!(prompt.contains("`project_init` only if the user asks"));
}
#[test]
fn a_manifest_project_carries_no_inference_caveat() {
let mut ctx = context();
ctx.project = Some(Project::new("demo"));
assert!(!ctx
.system_prompt()
.contains("read from the repository itself"));
}
#[tokio::test]
async fn a_soroban_repo_without_procyon_state_is_still_a_project() {
let temp = tempfile::tempdir().unwrap();
let contract = temp.path().join("contracts").join("counter");
tokio::fs::create_dir_all(&contract).await.unwrap();
tokio::fs::write(
contract.join("Cargo.toml"),
"[package]\nname = \"counter\"\nversion = \"0.1.0\"\n\n[dependencies]\nsoroban-sdk = \"22.0.1\"\n",
)
.await
.unwrap();
let ctx = WorkspaceContext::gather(temp.path(), &[]).await;
let project = ctx
.project
.as_ref()
.expect("contracts/counter is a project");
assert_eq!(project.contracts[0].name, "counter");
assert!(!ctx
.system_prompt()
.contains("no .procyon/project.toml found"));
}
#[tokio::test]
async fn gathering_in_a_directory_without_a_project_does_not_fail() {
let temp = tempfile::tempdir().unwrap();
let ctx = WorkspaceContext::gather(temp.path(), &[]).await;
assert!(ctx.project.is_none());
assert!(ctx.accounts.is_empty());
assert!(ctx.system_prompt().contains("no .procyon/project.toml"));
}
}
#[cfg(test)]
mod dump {
use super::*;
#[tokio::test]
#[ignore]
async fn dump_real_prompt() {
let cwd = std::env::current_dir().unwrap();
let servers = match crate::config::AppConfig::load() {
Ok(cfg) => crate::mcp::load_servers(&cfg.mcp_servers).await.1,
Err(_) => Vec::new(),
};
let prompt = WorkspaceContext::gather(&cwd, &servers)
.await
.system_prompt();
println!("{}", prompt);
assert!(
prompt.contains(&cwd.display().to_string()),
"the prompt must name the real workspace"
);
assert!(prompt.contains("## Environment"));
assert!(prompt.contains("## Working rules"));
}
}