rsclaw 2026.5.1

AI Agent Engine Compatible with OpenClaw
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! System prompt builders — base prompt, full prompt, help text, and helpers.
//!
//! Extracted from `runtime.rs` to reduce file size.

use super::workspace::WorkspaceContext;
use crate::skill::SkillRegistry;

/// Read-only commands that are always allowed for any agent (regardless of
/// allowedCommands).
pub(crate) const READONLY_COMMANDS: &[&str] = &[
    "/help", "/version", "/status", "/health", "/uptime", "/models", "/ctx", "/btw", "/clear",
    "/compact", "/history", "/cron", "/abort", "/loop", "/task",
];

/// Format a Duration as human-readable string.
pub(crate) fn format_duration(d: std::time::Duration) -> String {
    let secs = d.as_secs();
    let days = secs / 86400;
    let hours = (secs % 86400) / 3600;
    let mins = (secs % 3600) / 60;
    let s = secs % 60;
    if days > 0 {
        format!("{days}d {hours}h {mins}m")
    } else if hours > 0 {
        format!("{hours}h {mins}m {s}s")
    } else if mins > 0 {
        format!("{mins}m {s}s")
    } else {
        format!("{s}s")
    }
}

/// Build filtered help text based on allowed commands and language.
pub(crate) fn build_help_text_filtered(allowed: &str, lang: &str) -> String {
    let full = allowed == "*";
    let zh = lang == "zh";
    let has = |cmd: &str| -> bool {
        if full { return true; }
        READONLY_COMMANDS.iter().any(|c| *c == cmd) || allowed.split('|').any(|a| a.trim() == cmd)
    };

    let mut h = String::from(if zh { "可用命令:\n\n" } else { "Available commands:\n\n" });

    if has("/run") || has("/find") || has("/grep") {
        h.push_str(if zh { "终端:\n" } else { "Shell:\n" });
        if has("/run") {
            h.push_str(if zh { "  /run <命令>       执行终端命令\n  $ <命令>           执行终端命令(快捷方式)\n" } else { "  /run <cmd>        Execute a shell command\n  $ <cmd>           Execute a shell command (shortcut)\n" });
        }
        if has("/find") { h.push_str(if zh { "  /find <模式>      按名称查找文件\n" } else { "  /find <pattern>   Find files by name\n" }); }
        if has("/grep") { h.push_str(if zh { "  /grep <模式>      搜索文件内容\n" } else { "  /grep <pattern>   Search file contents\n" }); }
        h.push('\n');
    }

    if has("/read") || has("/write") || has("/ls") {
        h.push_str(if zh { "文件:\n" } else { "Files:\n" });
        if has("/read") { h.push_str(if zh { "  /read <路径>      读取文件\n" } else { "  /read <path>      Read a file\n" }); }
        if has("/write") { h.push_str(if zh { "  /write <路径> <内容>  写入文件\n" } else { "  /write <path> <content>  Write to a file\n" }); }
        if has("/ls") { h.push_str(if zh { "  /ls [路径]        列出目录\n" } else { "  /ls [path]        List directory\n" }); }
        h.push('\n');
    }

    if has("/search") || has("/fetch") || has("/screenshot") || has("/ss") {
        h.push_str(if zh { "搜索与网页:\n" } else { "Search & Web:\n" });
        if has("/search") { h.push_str(if zh { "  /search <关键词>  搜索网页\n" } else { "  /search <query>   Search the web\n" }); }
        if has("/fetch") { h.push_str(if zh { "  /fetch <网址>     抓取网页内容\n" } else { "  /fetch <url>      Fetch a web page\n" }); }
        if has("/screenshot") { h.push_str(if zh { "  /screenshot <网址> 网页截图\n" } else { "  /screenshot <url> Screenshot a web page\n" }); }
        if has("/ss") { h.push_str(if zh { "  /ss               桌面截图\n" } else { "  /ss               Screenshot desktop\n" }); }
        h.push('\n');
    }

    if has("/remember") || has("/recall") {
        h.push_str(if zh { "记忆:\n" } else { "Memory:\n" });
        if has("/remember") { h.push_str(if zh { "  /remember <文本>  保存到记忆\n" } else { "  /remember <text>  Save to memory\n" }); }
        if has("/recall") { h.push_str(if zh { "  /recall <关键词>  搜索记忆\n" } else { "  /recall <query>   Search memory\n" }); }
        h.push('\n');
    }

    h.push_str(if zh { "背景上下文:\n" } else { "Background Context:\n" });
    h.push_str(if zh { "  /ctx <文本>              添加持久上下文\n" } else { "  /ctx <text>              Add persistent context\n" });
    h.push_str(if zh { "  /ctx --ttl <N> <文本>    添加上下文(N轮后过期)\n" } else { "  /ctx --ttl <N> <text>    Add context (expires in N turns)\n" });
    if full { h.push_str(if zh { "  /ctx --global <文本>     添加全局上下文\n" } else { "  /ctx --global <text>     Add global context (all sessions)\n" }); }
    h.push_str(if zh { "  /ctx --list              列出活跃上下文\n" } else { "  /ctx --list              List active context entries\n" });
    h.push_str(if zh { "  /ctx --remove <id>       移除指定上下文\n" } else { "  /ctx --remove <id>       Remove entry by id\n" });
    h.push_str(if zh { "  /ctx --clear             清除当前会话所有上下文\n" } else { "  /ctx --clear             Clear all context for this session\n" });
    h.push('\n');

    h.push_str(if zh { "快速提问:\n" } else { "Side Query:\n" });
    h.push_str(if zh { "  /btw <问题>              快速查询(不调用工具)\n" } else { "  /btw <question>          Quick query (no tools, ephemeral)\n" });
    h.push('\n');

    if full {
        h.push_str(if zh { "工具(聚合):\n" } else { "Tools (consolidated):\n" });
        h.push_str(if zh { "  memory   搜索/获取/保存/删除长期记忆\n" } else { "  memory   search/get/put/delete long-term memory\n" });
        h.push_str(if zh { "  session  发送/列表/历史/状态\n" } else { "  session  send/list/history/status for sessions\n" });
        h.push_str(if zh { "  agent    创建/任务/列表/终止子智能体\n" } else { "  agent    spawn/task/list/kill sub-agents\n" });
        h.push_str(if zh { "  channel  发送/回复/置顶/删除跨渠道消息\n" } else { "  channel  send/reply/pin/delete across channels\n" });
        h.push('\n');
    }

    h.push_str(if zh { "系统:\n" } else { "System:\n" });
    h.push_str(if zh { "  /status           网关状态\n" } else { "  /status           Gateway status\n" });
    h.push_str(if zh { "  /version          查看版本\n" } else { "  /version          Show version\n" });
    h.push_str(if zh { "  /models           列出模型\n" } else { "  /models           List models\n" });
    if has("/model") { h.push_str(if zh { "  /model <名称>     切换模型\n" } else { "  /model <name>     Switch model\n" }); }
    h.push_str(if zh { "  /uptime           查看运行时长\n" } else { "  /uptime           Show uptime\n" });
    h.push('\n');

    h.push_str(if zh { "会话:\n" } else { "Session:\n" });
    h.push_str(if zh { "  /clear            清除会话\n" } else { "  /clear            Clear session\n" });
    h.push_str(if zh { "  /compact          压缩会话并保存记忆\n" } else { "  /compact          Compact session & save to memory\n" });
    h.push_str(if zh { "  /abort            终止当前任务\n" } else { "  /abort            Abort running task\n" });
    if has("/reset") { h.push_str(if zh { "  /reset            重置会话\n" } else { "  /reset            Reset session\n" }); }
    h.push_str(if zh { "  /voice            语音回复模式\n" } else { "  /voice            Voice reply mode\n" });
    h.push_str(if zh { "  /text             文字回复模式\n" } else { "  /text             Text reply mode\n" });
    h.push_str(if zh { "  /history [n]      查看历史\n" } else { "  /history [n]      Show history\n" });
    if has("/sessions") { h.push_str(if zh { "  /sessions         列出会话\n" } else { "  /sessions         List sessions\n" }); }
    h.push('\n');

    h.push_str(if zh { "定时任务:\n" } else { "Cron:\n" });
    h.push_str(if zh { "  /cron list        列出定时任务\n" } else { "  /cron list        List cron jobs\n" });
    h.push_str(if zh { "  /loop <间隔> <提示词>  循环执行(如 /loop 5m 检查邮件)\n" } else { "  /loop <interval> <prompt>  Recurring task (e.g. /loop 5m check mail)\n" });
    h.push('\n');

    h.push_str(if zh { "任务模式:\n" } else { "Task mode:\n" });
    h.push_str(if zh { "  /task <描述>             多轮执行任务\n  /task -n <N> -t <时长> <描述>  指定轮数和超时\n  /task -h                 查看 /task 完整帮助\n" } else { "  /task <desc>              Run a multi-turn task\n  /task -n <N> -t <dur> <desc>  Specify max turns and timeout\n  /task -h                  Full /task help\n" });
    h.push('\n');

    if has("/send") {
        h.push_str(if zh { "消息:\n" } else { "Messaging:\n" });
        h.push_str(if zh { "  /send <目标> <消息>  发送消息\n" } else { "  /send <target> <msg>  Send a message\n" });
        h.push('\n');
    }

    if has("/skill") {
        h.push_str(if zh { "技能:\n" } else { "Skill:\n" });
        h.push_str("  /skill install <name>\n  /skill list\n  /skill search <query>\n");
        h.push('\n');
    }

    if full {
        h.push_str(if zh { "上传限制:\n" } else { "Upload & Limits:\n" });
        h.push_str(if zh {
            "  /get_upload_size           查看上传大小限制\n  /set_upload_size <MB>      设置大小限制\n  /get_upload_chars          查看文本字符限制\n  /set_upload_chars <N>      设置字符限制\n  /config_upload_size <MB>   持久化大小限制\n  /config_upload_chars <N>   持久化字符限制\n"
        } else {
            "  /get_upload_size           Show upload size limit\n  /set_upload_size <MB>      Set size limit (runtime)\n  /get_upload_chars          Show text char limit\n  /set_upload_chars <N>      Set char limit (runtime)\n  /config_upload_size <MB>   Set size limit (persistent)\n  /config_upload_chars <N>   Set char limit (persistent)\n"
        });
        h.push('\n');
    }

    h.push_str(if zh { "直接输入消息即可与AI对话。" } else { "Type any message without / to chat with the AI agent." });
    h
}

// ---------------------------------------------------------------------------
// Dynamic context helper (used by KV cache modes 1 & 2)
// ---------------------------------------------------------------------------

/// Build the dynamic date/time context line.
///
/// When `kv_cache_mode >= 1`, this is injected per-turn into the user
/// message instead of the system prompt, to preserve KV cache prefix stability.
#[allow(dead_code)]
pub(crate) fn build_date_context() -> String {
    let now = chrono::Local::now();
    use chrono::Datelike;
    let weekday = now.date_naive().weekday().num_days_from_monday();
    let last_friday = if weekday >= 4 {
        now.date_naive() - chrono::Duration::days((weekday - 4) as i64)
    } else {
        now.date_naive() - chrono::Duration::days((weekday + 3) as i64)
    };
    let yesterday = now.date_naive() - chrono::Duration::days(1);
    format!(
        "Current date: {} ({}). Yesterday: {}. Last Friday: {}.",
        now.format("%Y-%m-%d %H:%M"),
        now.format("%A"),
        yesterday.format("%Y-%m-%d"),
        last_friday.format("%Y-%m-%d"),
    )
}

// ---------------------------------------------------------------------------
// System prompt builder
// ---------------------------------------------------------------------------

/// Build the base system prompt shared by main agent and sub agents.
///
/// Contains: language directive, platform info, command safety rules.
/// Date/time and other dynamic content is injected per-turn into the user
/// message by the runtime, NOT here, to preserve KV cache prefix stability.
pub(crate) fn build_base_system_prompt(config: &crate::config::schema::Config) -> Vec<String> {
    let mut parts: Vec<String> = Vec::new();

    // Language directive is stable (doesn't change per-turn).
    if let Some(lang) = config.gateway.as_ref().and_then(|g| g.language.as_deref()) {
        parts.push(format!(
            "Default response language: {lang}. Always reply in {lang} unless the user explicitly uses another language."
        ));
    }

    // Platform information so LLM generates correct shell commands.
    let platform_info = if cfg!(target_os = "windows") {
        "Platform: Windows. Shell: PowerShell. \
         Use PowerShell commands: Get-ChildItem (or dir), Get-Content, Get-Date, Select-Object -Last N (tail). \
         Pipes and filters work naturally: | Where-Object, | Select-Object, | Sort-Object. \
         Paths: backslash or forward slash both work. \
         Examples: Get-Date -Format 'yyyy-MM-dd'; Get-ChildItem | Select-Object -Last 5; Get-Content file.txt."
    } else if cfg!(target_os = "macos") {
        "Platform: macOS. Shell: bash/zsh. Standard Unix commands available (ls, cat, grep, tail, date)."
    } else {
        "Platform: Linux. Shell: bash/sh. Standard Unix commands available (ls, cat, grep, tail, date)."
    };
    parts.push(platform_info.to_string());

    // Windows command safety rules (only on Windows builds).
    if cfg!(target_os = "windows") {
        parts.push(
            "<windows_command_safety>\n\
             Windows command safety rules (ALL mandatory):\n\
             1. Do not wrap a command in an extra shell layer such as `cmd /c`, `powershell -Command`, or `pwsh -Command` unless strictly necessary.\n\
             2. For destructive file operations, only use a fully specified absolute path.\n\
             3. Never generate a command whose quoting, escaping, or trailing backslashes could cause the target path to be truncated or reinterpreted.\n\
             4. Any destructive operation outside the workspace requires explicit user approval.\n\
             5. If a destructive command fails, do NOT retry with workarounds or alternate commands. Stop, explain the failure, and ask the user.\n\
             </windows_command_safety>"
                .to_owned(),
        );
    }

    // Agent loop guidance (helps small models understand the iteration pattern).
    parts.push(
        "<agent_loop>\n\
         You are operating in an agent loop:\n\
         1. Analyze: understand the user's intent and current state\n\
         2. Plan: decide which tool to use next\n\
         3. Execute: call the tool\n\
         4. Observe: check the result\n\
         5. Iterate: repeat until the task is complete, then reply to the user\n\
         If a tool call fails, do NOT retry with the same arguments. Try a different approach or inform the user.\n\
         \n\
         [ANTI-HALLUCINATION — HARD RULES]\n\
         1. DO NOT fabricate numbers, dates, temperatures, prices, names, URLs, or any concrete facts.\n\
         2. DO NOT claim to have executed an action unless you actually made the tool call.\n\
            - If you say \"I searched\", \"I checked\", \"I delegated to X\", \"I ran Y\" — there MUST be a tool_call.\n\
            - Claiming an action without calling the tool is LYING to the user.\n\
            - If a tool is unavailable or you don't want to use it, say that honestly.\n\
         3. If a tool cannot retrieve real data (search empty, API down, access denied):\n\
            - Tell the user EXACTLY which tool failed and why.\n\
            - Ask the user if they want you to try a different approach.\n\
         Fabricating facts or pretending to have executed actions destroys user trust.\n\
         It is always better to say \"我没查到\" / \"I couldn't retrieve that\" / \"I haven't called that tool yet\"\n\
         than to invent plausible-looking but made-up values or fake action claims.\n\
         \n\
         When you need a Unix timestamp or today's date, use a shell command (e.g. `date`) — never assume or calculate it yourself.\n\
         \n\
         [Voice — HARD RULE]\n\
         Always speak directly to the user in second person (你/您/you). Never produce\n\
         third-person after-action reports about the user (e.g. \"用户东升通过...完成了...\")\n\
         or narrate what \"the user\" did. Reports, summaries, and status updates are\n\
         addressed TO the user, not ABOUT them. Do not invent completed steps — only\n\
         report what tools actually returned.\n\
         </agent_loop>"
            .to_owned(),
    );

    // Output formatting and data integrity rules (always included).
    parts.push(
        "[Output format rules]\n\
         - Avoid Markdown headings (#, ##, ###) in chat replies.\n\
         - Use **bold text** or section markers for sections.\n\
         - Use 1. or - for lists.\n\
         - Do NOT use Markdown tables (|---|). Use \"label: value\" format instead.\n\
         \n[Data integrity rules]\n\
         - NEVER truncate or shorten ANY text, strings, numbers, or identifiers.\n\
         - Copy ALL values EXACTLY: UUIDs, IDs, IP addresses, paths, URLs, code, data.\n\
         - If you see truncated data in context, report it as incomplete."
            .to_owned(),
    );

    parts
}

/// Build the full system prompt for the main agent (base + workspace + skills + tools).
pub(crate) fn build_system_prompt(
    ws_ctx: &WorkspaceContext,
    skills: &SkillRegistry,
    config: &crate::config::schema::Config,
) -> String {
    let mut parts = build_base_system_prompt(config);

    // Tool usage guidance
    {
        parts.push(
            "## Tool Usage Guidelines\n\
             ### File Operations (use dedicated tools, NOT execute_command)\n\
             - List directory: `list_dir`. Find files: `search_file`. Search contents: `search_content`.\n\
             - Read file: `read_file`. Write/create file: `write_file`.\n\
             - Documents (xlsx/docx/pdf/pptx): use `doc` tool.\n\
             - Reserve `execute_command` for system commands with no dedicated tool.\n\
             ### Completion Discipline\n\
             - Have enough info to answer? STOP and reply immediately.\n\
             - Do NOT repeat a tool call that already returned useful results.\n\
             - One successful search/fetch is usually enough. Two is the maximum.\n\
             ### Agent & Task Delegation\n\
             Delegate work to sub-agents for parallelism, never block.\n\
             - `agent` action=task for background sub-tasks. Specify `toolset` matching the task.\n\
             - Independent tasks -> dispatch ALL at once in parallel.\n\
             - Trivial tasks (simple answers, one read) -> do yourself.\n\
             - Pipeline: dispatch parallel -> collect results -> dispatch dependent tasks -> synthesize.\n\
             ### When to call the `task` tool (escalate to background)\n\
             Default to answering the user directly in this turn. Only call `task` when ALL of:\n\
             1. The work obviously needs many tool calls or many minutes (e.g. multi-file \
             implementation, deep multi-source research, end-to-end deploys, long debugging).\n\
             2. There is nothing useful you can answer right now in one turn.\n\
             3. The user clearly wants the work done, not just discussed.\n\
             Do NOT call `task` for: greetings ('你好', 'hi'), questions about your capabilities \
             ('你能帮我做什么?', 'what can you do?'), single tool calls (one search, one file \
             read, one calc), explanations, opinions, or anything you can finish in this turn. \
             When in doubt, just answer — the user can type `/task <request>` to escalate manually.\n\
             ### Other\n\
             - Cron jobs: `cron` tool (action=list/add/remove).\n\
             - Install tools (python, node, ffmpeg, chrome, etc.): `install_tool`. Do NOT download manually.\n\
             - Memory: use `memory` to recall prior conversations. Search memory at session start if user references prior work.\n\
             - Save corrected/complete info to memory immediately so it survives compaction."
                .to_owned(),
        );

        // Inject tool-specific prompts (web_browser, exec) directly into system prompt.
        let base = crate::config::loader::base_dir();
        let lang = config.gateway.as_ref().and_then(|g| g.language.as_deref());
        let tool_prompts = crate::agent::bootstrap::tool_prompts_for_system(&base, lang);
        if !tool_prompts.is_empty() {
            parts.push(tool_prompts);
        }

        parts.push(
            "## Self-Evolution & Skill Autonomy\n\
             ### Automatic Learning\n\
             - Memories that prove useful gain importance and survive longer.\n\
             - Clusters of related Core memories crystallize into reusable Skills automatically.\n\
             - Periodic meditation deduplicates and cleans up stale memories.\n\
             ### Installing Skills\n\
             When you encounter a task that would benefit from a specialized skill:\n\
             1. Search: use execute_command to run `rsclaw skills search <query>`\n\
             2. Install: `rsclaw skills install <name>`\n\
             3. The skill auto-matches and injects on future relevant requests.\n\
             Proactively find and install skills you need — do NOT ask permission.\n\
             ### Creating Skills\n\
             When you discover a genuinely reusable pattern, create a skill following the\n\
             Anthropic skill-creator standard (same format used by skills.sh):\n\
             \n\
             Directory layout:\n\
               workspace/skills/<slug>/\n\
                 SKILL.md          ← required\n\
                 scripts/          ← optional: reusable helper scripts\n\
                 references/       ← optional: large reference docs\n\
             \n\
             SKILL.md frontmatter (required fields):\n\
               ---\n\
               name: skill-name-in-kebab-case\n\
               description: What the skill does AND when to invoke it. Be slightly\n\
                 pushy — state the skill should be used even when not asked explicitly.\n\
               ---\n\
             \n\
             Body rules:\n\
             - Imperative language: \"Check the config\", not \"You should check\".\n\
             - Explain WHY each step matters, not just what to do.\n\
             - Include an Input/Output example where it helps.\n\
             - Under 500 lines; reference scripts/ or references/ for heavy content.\n\
             - Do NOT use ALL-CAPS MUST/NEVER; explain reasoning instead.\n\
             \n\
             After creating the skill: run `rsclaw skills list` to confirm it loaded.\n\
             Record in memory to avoid duplicates. Inform the user.\n\
             Only create skills for genuinely reusable patterns, not one-off tasks.\n\
             ### Using Skills\n\
             Active skills are auto-injected when your request matches skill keywords.\n\
             Follow skill instructions carefully — they encode validated experience.\n\
             If a skill's approach fails, fall back to general methods and update the skill."
                .to_owned(),
        );
    }

    // Workspace path anchor. Without this the agent has no idea which
    // directory is "yours" and tends to globally search the rsclaw base
    // dir (~/.rsclaw) when the user says "look at my .md files",
    // sweeping up `tools/`, `skills/`, `models/`, `var/data/` etc.
    parts.push(format!(
        "## Workspace\n\
         Your workspace is `{ws}`. ALL relative paths in tool calls (read_file, \
         write_file, list_dir, search_file, execute_command cwd) resolve against \
         this directory. When the user says \"my files\" / \"my .md files\" / \
         \"the workspace\", they mean files inside this directory — NOT the \
         rsclaw base dir at `~/.rsclaw/` which contains internal state \
         (skills, plugins, models, credentials, var/data, tools/) that you \
         must not modify.",
        ws = ws_ctx.workspace_dir.display()
    ));

    // Workspace files segment.
    let ws_segment = ws_ctx.to_prompt_segment();
    if !ws_segment.is_empty() {
        parts.push(ws_segment);
    }

    // Available skills — name + description + on-disk path. The full SKILL.md
    // (and any references/) stays on disk so the prompt budget isn't blown
    // by every installed skill, but the LLM gets enough metadata to know
    // when to invoke each one. Includes the explicit "read SKILL.md and
    // references/ first" instruction so agents stop guessing CLI flags
    // (e.g. flyai shipped --origin / --dep-date but agents kept inventing
    // --depCity / --depDate from intuition).
    if !skills.is_empty() {
        let lines: Vec<_> = skills
            .all()
            .map(|s| {
                let desc = s
                    .description
                    .as_deref()
                    .map(|d| {
                        // Compact to one line, cap so a verbose skill can't
                        // monopolise the prompt.
                        let oneline = d.replace('\n', " ");
                        let trimmed = oneline.trim();
                        if trimmed.chars().count() > 200 {
                            let cut: String =
                                trimmed.chars().take(200).collect();
                            format!("{cut}")
                        } else if trimmed.is_empty() {
                            String::new()
                        } else {
                            format!("{trimmed}")
                        }
                    })
                    .unwrap_or_default();
                format!("- {}{desc}\n  dir: {}", s.name, s.dir.display())
            })
            .collect();
        if !lines.is_empty() {
            parts.push(format!(
                "Available skills (read each skill's SKILL.md and any references/*.md \
                 BEFORE invoking its CLI for the first time — exact flag names live \
                 there, guessing them is the #1 failure mode):\n{}",
                lines.join("\n")
            ));
        }
    }

    parts.join("\n\n")
}

/// Build a minimal system prompt for internal sessions (heartbeat/cron/system).
///
/// Internal sessions only have the `memory` tool available (see
/// `runtime.rs` tool filtering). Injecting the full system prompt
/// (~3k tokens of skills, workspace, tool guidelines) wastes context
/// on every tick. This minimal prompt keeps the identity + the single
/// instruction the session actually needs.
pub(crate) fn build_minimal_system_prompt() -> String {
    "You are an internal rsclaw task agent. Only the `memory` tool is available.\n\
     Follow the instructions in the user message. Be terse — no preamble, no\n\
     closing pleasantries. If nothing actionable to report, reply HEARTBEAT_OK."
        .to_owned()
}

/// Return a relative time label for memory recall.
/// LLMs can't do date arithmetic, so we use relative descriptions.
pub(crate) fn memory_age_label(now_ts: i64, created_at: i64) -> String {
    let age_secs = (now_ts - created_at).max(0);
    let days = age_secs / 86400;
    match days {
        0 => "today".to_owned(),
        1 => "yesterday".to_owned(),
        2..=6 => format!("{days} days ago"),
        7..=13 => "~1 week ago".to_owned(),
        14..=29 => format!("{} weeks ago", days / 7),
        30..=59 => "~1 month ago — may be outdated, verify before using".to_owned(),
        60..=364 => format!("{} months ago — may be outdated, verify before using", days / 30),
        365..=729 => "~1 year ago — likely outdated, verify before using".to_owned(),
        _ => format!("~{} years ago — likely outdated, verify before using", days / 365),
    }
}