use anyhow::Result;
use std::path::{Path, PathBuf};
use rsclaw_memory::{MemDocTier, MemoryDoc, global_store};
const USER_BEGIN_MARKER: &str = "<!-- BEGIN rsclaw:user-profile -->";
const USER_END_MARKER: &str = "<!-- END rsclaw:user-profile -->";
const USER_FILE_HEADER: &str =
"<!-- auto-generated by rsclaw cap_live; refreshed on every /cap bind. \
Manual edits to this file will be overwritten. -->";
const MAX_PROFILE_ENTRIES: usize = 30;
pub fn is_default_workspace(cwd: &Path) -> bool {
let default = default_workspace_path();
match (std::fs::canonicalize(cwd), std::fs::canonicalize(&default)) {
(Ok(a), Ok(b)) => a == b,
_ => cwd == default.as_path(),
}
}
fn default_workspace_path() -> PathBuf {
let base = rsclaw_config::loader::base_dir();
if let Ok(cfg) = rsclaw_config::load()
&& let Some(ws) = cfg
.raw
.agents
.as_ref()
.and_then(|a| a.defaults.as_ref())
.and_then(|d| d.workspace.as_deref())
{
return PathBuf::from(ws);
}
base.join("workspace")
}
pub async fn write_identity_files(
cwd: &Path,
plugins: &[String],
skills: &[String],
) -> Result<()> {
if !is_default_workspace(cwd) {
tracing::debug!(
target: "cap",
cwd = %cwd.display(),
"cap identity: skipping non-default workspace (user-specified path)"
);
return Ok(());
}
std::fs::create_dir_all(cwd)?;
let user_body = build_user_md_body().await;
let user_path = cwd.join("USER.md");
let agents_path = cwd.join("AGENTS.md");
let existing_user = std::fs::read_to_string(&user_path).ok();
let touch_user = match existing_user.as_deref() {
None => true,
Some(s) => is_rsclaw_authored(s),
};
let user_full = format!("{}\n\n{}", USER_FILE_HEADER, user_body);
if touch_user {
if let Err(e) = std::fs::write(&user_path, &user_full) {
tracing::warn!(
target: "cap",
path = %user_path.display(),
error = %e,
"cap identity: failed to write USER.md"
);
return Ok(());
}
} else {
tracing::info!(
target: "cap",
path = %user_path.display(),
"cap identity: USER.md is user-authored — preserving verbatim"
);
}
let user_body_for_agents = match existing_user.as_deref() {
Some(s) if !is_rsclaw_authored(s) => strip_header_lines(s),
_ => user_body,
};
let agents_existing = std::fs::read_to_string(&agents_path).ok();
let agents_new =
compose_agents_md(agents_existing.as_deref(), &user_body_for_agents, plugins, skills);
let touch_agents = match agents_existing.as_deref() {
None => true,
Some(s) => has_markers(s) || is_rsclaw_authored(s),
};
if touch_agents {
if let Err(e) = std::fs::write(&agents_path, &agents_new) {
tracing::warn!(
target: "cap",
path = %agents_path.display(),
error = %e,
"cap identity: failed to write AGENTS.md"
);
return Ok(());
}
} else {
tracing::info!(
target: "cap",
path = %agents_path.display(),
"cap identity: AGENTS.md is user-authored — preserving verbatim"
);
}
tracing::info!(
target: "cap",
workspace = %cwd.display(),
wrote_user = touch_user,
wrote_agents = touch_agents,
"cap identity: refresh complete"
);
Ok(())
}
fn strip_header_lines(s: &str) -> String {
let mut lines: Vec<&str> = s.lines().collect();
while let Some(first) = lines.first() {
let t = first.trim();
if t.is_empty() || (t.starts_with("<!--") && t.ends_with("-->")) {
lines.remove(0);
} else {
break;
}
}
lines.join("\n")
}
fn compose_agents_md(
existing: Option<&str>,
user_body: &str,
plugins: &[String],
skills: &[String],
) -> String {
let managed = managed_region(plugins, skills, user_body);
match existing {
Some(s) if has_markers(s) => splice_between_markers(s, &managed),
Some(s) if !is_rsclaw_authored(s) => s.to_owned(),
Some(_) | None => default_template(&managed),
}
}
fn managed_region(plugins: &[String], skills: &[String], user_body: &str) -> String {
let mut s = String::new();
if !plugins.is_empty() || !skills.is_empty() {
s.push_str("Loaded capabilities — prefer these over scraping the web:\n\n");
if !plugins.is_empty() {
s.push_str(&format!(
"**Loaded plugins** (call: `rsclaw plugins call <plugin>.<tool> --args '<json>'`, \
e.g. `rsclaw plugins call astock.quote --args '{{\"code\":\"300033\"}}'`): {}\n\n",
plugins.join(", ")
));
}
if !skills.is_empty() {
s.push_str(&format!(
"**Installed skills** (load + follow: `rsclaw skills use <name>`): {}\n\n",
skills.join(", ")
));
}
}
s.push_str(user_body.trim());
s
}
fn has_markers(s: &str) -> bool {
s.contains(USER_BEGIN_MARKER) && s.contains(USER_END_MARKER)
}
fn is_rsclaw_authored(s: &str) -> bool {
s.contains("auto-generated by rsclaw cap_live")
}
fn splice_between_markers(existing: &str, user_body: &str) -> String {
let Some(begin_off) = existing.find(USER_BEGIN_MARKER) else {
return default_template(user_body);
};
let Some(end_off) = existing.find(USER_END_MARKER) else {
return default_template(user_body);
};
if end_off < begin_off {
return default_template(user_body);
}
let head = &existing[..begin_off + USER_BEGIN_MARKER.len()];
let tail = &existing[end_off..];
format!("{head}\n\n{}\n\n{tail}", user_body.trim())
}
fn default_template(user_body: &str) -> String {
format!(
"<!-- auto-generated by rsclaw cap_live; manual edits between the BEGIN/END \
markers below are overwritten on every /cap bind, anywhere else is preserved. -->
# Coding subagent context
You are a coding subagent that **rsclaw** (the main chat-side agent) has bridged \
into this user's IM session. The user can still read every word you write. Be \
concise. Verify with tools before asserting facts.
The `## Session context` section below is auto-generated and refreshed on every \
`/cap` bind: it lists the plugins/skills loaded right now plus user-profile hints \
from rsclaw's long-term memory. Treat the profile as hints, not gospel — when in \
doubt, ask or check.
## Session context
{USER_BEGIN_MARKER}
{}
{USER_END_MARKER}
## rsclaw helpers — USE THESE FIRST
You run in a shell with the `rsclaw` CLI on PATH. It exposes rsclaw's plugins, \
skills, memory and knowledge base as cross-process commands. **When a task is in \
a plugin's or skill's domain (A-share quotes, travel, image/video gen, etc.), \
call it through these commands instead of scraping the web or guessing** — they \
are faster, structured, and authenticated. All commands take `--json`; auth + \
URL are handled inside the binary.
```
# Plugins — domain capabilities (stocks, social media, media gen, …)
rsclaw plugins list # what's installed right now
rsclaw plugins describe <plugin> # its tools + arg schemas
rsclaw plugins call <plugin>.<tool> --args '<json>' # invoke a tool
# e.g. rsclaw plugins call astock.quote --args '{{\"code\":\"300033\"}}'
# Skills — packaged playbooks (booking, market queries, …). A skill is a
# markdown SOP (+ scripts), not a one-shot call: load it, then follow it.
rsclaw skills list # installed skills
rsclaw skills use <name> # print the full SOP + its dir, then follow it
rsclaw skills search \"<query>\" # find one to install
rsclaw skills install <name>
# Memory
rsclaw memory search \"<query>\" [--max-results N] [--json]
rsclaw memory save \"<fact>\" [--scope SCOPE] [--kind fact|note] [--pinned] [--json]
# Knowledge base
rsclaw kb search \"<query>\" [-k N] [--json]
rsclaw kb add <path-or-url> [--tag T ...] [--recursive]
# Messaging (send to IM channels rsclaw is wired to)
rsclaw message send --channel <wechat|feishu|...> --target <id> -m \"...\"
```
",
user_body.trim()
)
}
async fn build_user_md_body() -> String {
let Some(mem) = global_store() else {
return "_no memory store available — rsclaw has not loaded any user facts yet._"
.to_owned();
};
let docs = {
let store = mem.lock().await;
store.list_active()
};
let mut filtered: Vec<&MemoryDoc> = docs
.iter()
.filter(|d| !is_ephemeral_scope(&d.scope) && !is_ephemeral_kind(&d.kind))
.collect();
filtered.sort_by(|a, b| {
b.pinned
.cmp(&a.pinned)
.then_with(|| b.importance.partial_cmp(&a.importance).unwrap_or(std::cmp::Ordering::Equal))
.then_with(|| b.accessed_at.cmp(&a.accessed_at))
});
let chosen: Vec<&&MemoryDoc> = filtered.iter().take(MAX_PROFILE_ENTRIES).collect();
if chosen.is_empty() {
return "_no high-importance user facts in memory yet — write facts \
with `rsclaw memory save \"<fact>\" --pinned`._"
.to_owned();
}
let mut out = String::new();
for d in &chosen {
let pin = if d.pinned { " 📌" } else { "" };
let tier = match d.tier {
MemDocTier::Core => "core",
MemDocTier::Working => "working",
MemDocTier::Peripheral => "peripheral",
};
let one_line = d.text.replace(['\n', '\r'], " ");
out.push_str(&format!(
"- {one_line} _({kind}/{tier}, importance={imp:.2}){pin}_\n",
kind = d.kind,
imp = d.importance,
));
}
out
}
fn is_ephemeral_scope(scope: &str) -> bool {
scope.ends_with(":heartbeat") || scope.ends_with(":cron") || scope.ends_with(":system")
}
fn is_ephemeral_kind(kind: &str) -> bool {
matches!(kind, "session" | "turn" | "trace")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splice_replaces_only_between_markers() {
let existing = format!(
"# Custom header\n\nUser-written notes.\n\n## Profile\n\n{begin}\n\
OLD profile body\n{end}\n\n## Custom trailer\nMore user notes.",
begin = USER_BEGIN_MARKER,
end = USER_END_MARKER,
);
let new_body = "- new fact 1\n- new fact 2";
let out = compose_agents_md(Some(&existing), new_body, &[], &[]);
assert!(out.contains("# Custom header"));
assert!(out.contains("User-written notes."));
assert!(out.contains("## Custom trailer"));
assert!(out.contains("- new fact 1"));
assert!(!out.contains("OLD profile body"));
}
#[test]
fn no_markers_user_authored_preserved_verbatim() {
let existing = "# My project AGENTS.md\n\nManual instructions here.";
let out = compose_agents_md(Some(existing), "- new fact", &[], &[]);
assert_eq!(out, existing, "user-authored AGENTS.md should be untouched");
}
#[test]
fn no_markers_rsclaw_authored_gets_template_back() {
let existing = "<!-- auto-generated by rsclaw cap_live -->\n# Stuff";
let out = compose_agents_md(Some(existing), "- fact", &[], &[]);
assert!(out.contains(USER_BEGIN_MARKER));
assert!(out.contains("- fact"));
}
#[test]
fn missing_file_emits_default_template() {
let out = compose_agents_md(None, "- fact", &[], &[]);
assert!(out.contains(USER_BEGIN_MARKER));
assert!(out.contains(USER_END_MARKER));
assert!(out.contains("- fact"));
assert!(out.contains("rsclaw helpers"));
}
#[test]
fn corrupt_marker_order_falls_back_to_template() {
let existing = format!(
"junk\n{end}\nstuff\n{begin}\nmore",
begin = USER_BEGIN_MARKER,
end = USER_END_MARKER,
);
let out = compose_agents_md(Some(&existing), "- fact", &[], &[]);
assert!(out.contains(USER_BEGIN_MARKER));
let begin_at = out.find(USER_BEGIN_MARKER).unwrap();
let end_at = out.find(USER_END_MARKER).unwrap();
assert!(begin_at < end_at);
}
#[test]
fn strip_header_lines_drops_comments_and_blanks() {
let input = "<!-- some comment -->\n\n# User\n\n## Section\nbody\n";
let out = strip_header_lines(input);
assert!(out.starts_with("# User"));
assert!(!out.contains("some comment"));
}
#[test]
fn strip_header_lines_preserves_body() {
let input = "# Heading\nLine 2\n";
let out = strip_header_lines(input);
assert_eq!(out.trim_end(), "# Heading\nLine 2");
}
#[test]
fn ephemeral_filters() {
assert!(is_ephemeral_scope("agent:main:heartbeat"));
assert!(is_ephemeral_scope("agent:main:cron"));
assert!(!is_ephemeral_scope("agent:main"));
assert!(!is_ephemeral_scope("global"));
assert!(is_ephemeral_kind("session"));
assert!(is_ephemeral_kind("turn"));
assert!(!is_ephemeral_kind("fact"));
assert!(!is_ephemeral_kind("note"));
}
}