From 6f8f06d57f4f411aa8d87db43468523441a68402 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:53:03 -0400 Subject: [PATCH 01/17] feat: two-layer prompt architecture for managed agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents running in Sprout have no platform awareness — they don't know about channels, MCP tools, @mention syntax, or the other agents in the workspace. This adds a base layer prompt (compiled into sprout-acp via include_str!) that gives every agent reliable Sprout context on every turn, regardless of persona or runtime. Base layer: platform identity, MCP tool reference, communication patterns, workspace layout, startup recovery. Persona layer: unchanged role-specific content (Solo/Kit/Scout behavioral protocols, worktree discipline, quality bar). Also adds default model (claude-sonnet-4-20250514) to all three built-in personas so users don't need to pick at agent creation, and fixes a merge_personas() bug where .is_some() checks would cause infinite reset loops once built-ins carry non-None model values. --- crates/sprout-acp/src/base_prompt.md | 35 +++++ crates/sprout-acp/src/lib.rs | 5 + crates/sprout-acp/src/pool.rs | 2 + crates/sprout-acp/src/queue.rs | 55 +++++++- crates/sprout-persona/PERSONA_PACK_SPEC.md | 133 ++++++++++++++---- .../src-tauri/src/managed_agents/personas.rs | 12 +- 6 files changed, 205 insertions(+), 37 deletions(-) create mode 100644 crates/sprout-acp/src/base_prompt.md diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md new file mode 100644 index 000000000..852d37553 --- /dev/null +++ b/crates/sprout-acp/src/base_prompt.md @@ -0,0 +1,35 @@ +You are operating inside the Sprout platform — a Nostr-based messaging platform for human-agent collaboration. The sprout-acp harness bridges channel events to your session. + +## MCP Tools + +- `get_messages(channel_id, limit=50)` — fetch recent history (max 200 per call) +- `get_messages(channel_id, since=)` — fetch messages since timestamp; returns oldest-first when `since` is set without `before` +- `get_thread(channel_id, event_id)` — fetch a full thread by root event ID +- `get_feed()` — personalized feed of mentions and needs-action items across all channels +- `send_message(channel_id, content)` — post a new message to a channel +- `send_message(channel_id, content, parent_event_id)` — reply within an existing thread +- `search(q="your query")` — cross-channel full-text search + +## Communication Patterns + +- Address agents and humans with `@name` in message content. +- Use `parent_event_id` when responding to a thread; post a new message for new topics. +- There are no push notifications — poll for new messages using `since=`. + +## Startup Recovery + +On startup or after a gap: call `get_feed()` first to surface pending mentions and action items, then call `get_messages` on your assigned channels to catch up, then check the workspace `AGENTS.md` for team context. + +## Workspace Layout + +Persistent workspace at `$AGENT_CWD/` with the following directories: + +- `RESEARCH/` — findings and reference material +- `PLANS/` — project and task plans +- `GUIDES/` — how-to documentation +- `WORK_LOGS/` — timestamped activity logs +- `OUTBOX/` — drafts pending review or send +- `REPOS/` — checked-out source repositories +- `.scratch/` — ephemeral working files + +Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming and YAML frontmatter. `AGENTS.md` in the working directory lists active agents and their assigned roles. diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index 9de5586bd..73734b7b2 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -1076,6 +1076,11 @@ async fn tokio_main() -> Result<()> { max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), + base_prompt: if std::env::var("SPROUT_ACP_BASE_PROMPT_DISABLED").is_ok() { + None + } else { + Some(include_str!("base_prompt.md").to_string()) + }, heartbeat_prompt: config.heartbeat_prompt.clone(), cwd: std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 1397e7f23..e636e8f63 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -192,6 +192,7 @@ pub struct PromptContext { pub dedup_mode: DedupMode, pub system_prompt: Option, pub heartbeat_prompt: Option, + pub base_prompt: Option, pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, @@ -952,6 +953,7 @@ pub async fn run_prompt_task( let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned(); crate::queue::format_prompt( b, + ctx.base_prompt.as_deref(), ctx.system_prompt.as_deref(), agent_core_section.as_deref(), channel_info.as_ref(), diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index 4fb5865d6..8b0452bd8 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -945,12 +945,14 @@ fn format_conversation_context( /// Format a [`FlushBatch`] into a prompt string for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 1. `[System]` — system prompt (if configured) -/// 2. `[Context]` — scope, channel name, structural hints +/// 0. `[Base]\n{base_prompt}` — platform orientation (if configured) +/// 1. `[System]\n{system_prompt}` — if system prompt is set +/// 2. `[Context]` — scope, channel name, and contextual hints for the agent /// 3. `[Thread Context]` or `[Conversation Context]` — if fetched /// 4. `[Event]` / `[Sprout events]` — the triggering event(s) pub fn format_prompt( batch: &FlushBatch, + base_prompt: Option<&str>, system_prompt: Option<&str>, agent_core: Option<&str>, channel_info: Option<&PromptChannelInfo>, @@ -973,7 +975,12 @@ pub fn format_prompt( .map(|ci| ci.channel_type == "dm") .unwrap_or(false); - let mut sections: Vec = Vec::with_capacity(4); + let mut sections: Vec = Vec::with_capacity(5); + + // 0. Base prompt (platform-level, always first). + if let Some(bp) = base_prompt { + sections.push(format!("[Base]\n{bp}")); + } // 1. System prompt. if let Some(sp) = system_prompt { @@ -1432,6 +1439,7 @@ mod tests { let prompt = format_prompt( &batch, + None, Some("You are a triage bot."), None, None, @@ -1457,7 +1465,7 @@ mod tests { cancelled_events: vec![], }; let core = "[Agent Memory — core]\nbe helpful"; - let prompt = format_prompt(&batch, Some("sys"), Some(core), None, None, None); + let prompt = format_prompt(&batch, None, Some("sys"), Some(core), None, None, None); assert!( prompt.contains("[System]\nsys\n\n[Agent Memory — core]\nbe helpful"), "expected core block after [System]; got: {prompt}" @@ -1478,10 +1486,47 @@ mod tests { cancelled_events: vec![], }; let core = "[Agent Memory — core]\nbe helpful"; - let prompt = format_prompt(&batch, None, Some(core), None, None, None); + let prompt = format_prompt(&batch, None, None, Some(core), None, None, None); assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); } + // ── Test 11c: base prompt prepended before system prompt ───────────────── + + #[test] + fn test_format_prompt_with_base_prompt() { + let ch = Uuid::new_v4(); + let event = make_event("hello"); + + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + }; + + // Both base_prompt and system_prompt: [Base] comes first, then [System]. + let prompt = format_prompt( + &batch, + Some("Platform base."), + Some("Role prompt."), + None, + None, + None, + None, + ); + assert!(prompt.starts_with("[Base]\nPlatform base.\n\n[System]\nRole prompt.")); + + // Only base_prompt (no system_prompt): [Base] comes first, then [Context]. + let prompt = format_prompt(&batch, Some("Platform base."), None, None, None, None, None); + assert!(prompt.starts_with("[Base]\nPlatform base.\n\n[Context]")); + + // No base_prompt: no [Base] section emitted. + let prompt = format_prompt(&batch, None, None, None, None, None, None); + assert!(!prompt.contains("[Base]")); + } // ── Test 12: drop mode discards in-flight channel events ───────────────── #[test] diff --git a/crates/sprout-persona/PERSONA_PACK_SPEC.md b/crates/sprout-persona/PERSONA_PACK_SPEC.md index cbbeaecff..5ffc961ab 100644 --- a/crates/sprout-persona/PERSONA_PACK_SPEC.md +++ b/crates/sprout-persona/PERSONA_PACK_SPEC.md @@ -94,7 +94,7 @@ none of them override it. - **Extension mechanism**: Sprout-specific fields sit at the top level of `plugin.json` alongside OPS fields. No OPS core field is overloaded. - **`defaults`**: ignored entirely by OPS consumers. sprout-acp resolves it at deploy time before - constructing per-persona configurations (see Section 9 and Section 11). + constructing per-persona configurations (see Section 10 and Section 12). --- @@ -129,7 +129,7 @@ my-pack/ - `agents/` — all persona files. No nesting; flat directory. - `skills/` — one subdirectory per skill. Each skill directory contains a `SKILL.md` file. - Both `name:` and `description:` frontmatter fields are **required** — see Section 5. + Both `name:` and `description:` frontmatter fields are **required** — see Section 6. - `.plugin/` — OPS-required location for the manifest. - `hooks/` — optional; omit if no hooks are needed. - `instructions.md` — optional; omit if no pack-level instructions. @@ -146,7 +146,7 @@ A persona file is a markdown document with YAML frontmatter. The **YAML frontmat identity, skills, MCP servers, and behavioral config. The **markdown body** (everything after the closing `---`) is the agent's persona prompt text. -> **Note**: The persona prompt is currently delivered as a `[System]` prefix in the user message text (see Section 11). True system prompt injection (once at session creation rather than every turn) is planned — see Section 15. +> **Note**: The persona prompt is currently delivered as a `[System]` prefix in the user message text (see Section 12). True system prompt injection (once at session creation rather than every turn) is planned — see Section 16. ### Full Schema @@ -209,14 +209,14 @@ You are Lep, a security-focused code reviewer on the Meadow team. | `author` | string | ❌ | OPS compatibility field. | | `skills` | string[] | ❌ | Pack-relative paths to skill directories for this agent only. | | `mcp_servers` | object[] | ❌ | Per-persona MCP servers. Merged with pack-level `.mcp.json`. | -| `subscribe` | string[] | ❌ | Channels to monitor. See Section 9. | -| `triggers` | object | ❌ | Controls which messages activate a response. See Section 9. | -| `model` | string | ❌ | Model to use. See Section 9. | -| `temperature` | float | ❌ | Sampling temperature. See Section 9. | -| `max_context_tokens` | int | ❌ | Context window limit. See Section 9. | -| `thread_replies` | bool | ❌ | Reply in-thread when triggering message is in a thread. See Section 9. | -| `broadcast_replies` | bool | ❌ | Surface thread replies to the main channel. See Section 9. | -| `hooks` | object | ❌ | Lifecycle hooks. Harness-managed. See Section 8. | +| `subscribe` | string[] | ❌ | Channels to monitor. See Section 10. | +| `triggers` | object | ❌ | Controls which messages activate a response. See Section 10. | +| `model` | string | ❌ | Model to use. See Section 10. | +| `temperature` | float | ❌ | Sampling temperature. See Section 10. | +| `max_context_tokens` | int | ❌ | Context window limit. See Section 10. | +| `thread_replies` | bool | ❌ | Reply in-thread when triggering message is in a thread. See Section 10. | +| `broadcast_replies` | bool | ❌ | Surface thread replies to the main channel. See Section 10. | +| `hooks` | object | ❌ | Lifecycle hooks. Harness-managed. See Section 9. | > **Legacy alias**: The YAML key `respond_to` is accepted as an alias for `triggers` in persona frontmatter. In `plugin.json` defaults, both `triggers` and `respond_to` are accepted. The canonical key is `triggers`. @@ -228,7 +228,86 @@ files (agent runtimes typically do not read them). --- -## 5. Skills +## 5. Two-Layer Prompt Architecture + +sprout-acp assembles the agent's context from two distinct prompt layers before sending each +message. Understanding this layering is essential for persona authors — content that belongs in +one layer should not be duplicated in the other. + +### Prompt Section Order + +Each message delivered to the agent runtime includes these sections in order: + +``` +[Base] + + +[System] + + +--- +# Team Instructions + + +[Context] + + +[Thread/Conversation Context] + + +[Sprout event] + +``` + +### The `[Base]` Layer + +The `[Base]` layer is compiled into sprout-acp and is **identical for every agent**. It covers: + +| Content | Purpose | +|---------|---------| +| Platform identity | Tells the agent it is running inside Sprout and what that means | +| MCP tool reference | Documents the tools available via the connected MCP servers | +| Workspace layout | Describes `$AGENT_CWD`, skill discovery paths, and file conventions | +| Message polling | Explains how to check for new messages proactively | + +Pack authors do not write or configure the `[Base]` layer — it is maintained by the Sprout team +and updated in sprout-acp releases. + +**Disabling the base layer**: Set `SPROUT_ACP_BASE_PROMPT_DISABLED=1` in the sprout-acp process +environment to omit the `[Base]` section entirely. This is intended for testing and advanced +deployments where operators supply their own platform context. + +### The `[System]` Layer + +The `[System]` layer is the persona prompt — the markdown body of the `.persona.md` file. It is +**unique per agent** and defines the agent's role, identity, and behavioral rules. This is where +pack authors write their persona content. + +What belongs in `[System]`: + +| Content | Examples | +|---------|---------| +| Agent name and role | "You are Lep, a security-focused code reviewer" | +| Team protocols | Escalation rules, @-mention discipline, handoff conventions | +| Domain rules | Security checklists, review criteria, coding standards | +| Behavioral autonomy | When to act independently vs. when to ask | + +### Guidance for Pack Authors + +**Do not duplicate base layer content in persona prompts.** Users with the base layer enabled +(the default) would see that content twice per message. Specifically, do not re-explain: + +- How to use MCP tools (covered by `[Base]`) +- How to poll for new messages or use the `since` parameter (covered by `[Base]`) +- Workspace layout or skill loading mechanics (covered by `[Base]`) +- That the agent is running inside Sprout (covered by `[Base]`) + +Focus persona prompts on what makes this agent unique: its role, personality, domain expertise, +and team-specific protocols. + +--- + +## 6. Skills > **Implementation note**: Skill paths are stored as declared in persona frontmatter. Resolution > to `SKILL.md` `name:` fields and runtime copying to `$AGENT_CWD/.agents/skills/` is planned @@ -323,7 +402,7 @@ load(source: "security-review") ``` sprout-acp lists available skills in the user message prefix so the agent knows what's available. -See Section 11 for the full message format. +See Section 12 for the full message format. ### Skill File Format @@ -344,7 +423,7 @@ enforce required metadata fields (see PF-5). --- -## 6. MCP Server Configuration +## 7. MCP Server Configuration MCP servers provide external tool access (GitHub, Semgrep, databases, etc.). Configuration is defined at two levels: pack-level (shared across all agents) and per-persona (agent-specific). @@ -408,13 +487,13 @@ sprout-acp passes the merged config via `NewSessionRequest.mcp_servers`. **No `. --- -## 7. Pack-Level Instructions +## 8. Pack-Level Instructions `instructions.md` contains shared rules, coding standards, and team norms that apply to all agents in the pack. sprout-acp appends it to the persona prompt in the user message prefix. sprout-acp appends `instructions.md` to the persona prompt in the user message prefix (see -Section 11). **No file is written to disk.** +Section 12). **No file is written to disk.** **What does NOT work**: `.mdc` rule files (agent runtimes typically don't read them), `rules/` directory (no `--rules-dir` flag), relying on the pack's `AGENTS.md` for runtime injection (it's for human @@ -426,7 +505,7 @@ contributors only). --- -## 8. Lifecycle Hooks +## 9. Lifecycle Hooks > **Implementation note**: Hooks are parsed and validated at pack load time but not yet executed. > Hook execution is planned for a future release. @@ -492,7 +571,7 @@ sprout-acp means no hooks fire. --- -## 9. Behavioral Configuration +## 10. Behavioral Configuration The behavioral config fields in a persona's frontmatter control how the agent participates in Sprout conversations. These are all Sprout-specific — the agent runtime has no awareness of them. They sit @@ -763,7 +842,7 @@ All fields are consumed entirely by sprout-acp. None are passed to the agent run --- -## 10. Distribution +## 11. Distribution ### Phase 1: Zip File @@ -845,7 +924,7 @@ The Sprout desktop app can import persona packs via the Import button: --- -## 11. Delivery Mechanism Summary +## 12. Delivery Mechanism Summary How each pack component reaches the running agent: @@ -862,7 +941,7 @@ How each pack component reaches the running agent: > **Pack defaults are resolved at deploy time**, not at runtime. When sprout-acp loads a pack and > constructs per-persona session configurations, it merges the `defaults` object with each persona's -> frontmatter behavioral config fields (per the precedence model in Section 9) and stores the +> frontmatter behavioral config fields (per the precedence model in Section 10) and stores the > resulting effective configuration. The `defaults` object itself is not forwarded to the agent runtime or > stored in any runtime artifact — only the resolved per-persona values are used. @@ -892,7 +971,7 @@ Load a skill with: load(source: "skill-name") The `[System]` prefix re-sends the full persona prompt on every turn. True system prompt injection — calling `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()` -— fires once at session creation. This is planned work; see Section 15. +— fires once at session creation. This is planned work; see Section 16. ### What Does NOT Work (Anti-Pattern Reference) @@ -911,12 +990,12 @@ The `[System]` prefix re-sends the full persona prompt on every turn. True syste --- -## 12. Security Considerations +## 13. Security Considerations ### Secret Management Never embed secrets in pack files. Use `${VAR_NAME}` references in all `env` blocks. Currently, -`${VAR_NAME}` strings are passed through as literals to the agent runtime (see Section 6). When +`${VAR_NAME}` strings are passed through as literals to the agent runtime (see Section 7). When harness-side interpolation is implemented, sprout-acp will resolve them from the process environment at startup and refuse to start if any are unresolved. Inject secrets via your deployment mechanism (systemd env files, Vault, Kubernetes secrets, etc.). @@ -944,7 +1023,7 @@ both with the same caution as any untrusted prompt content. --- -## 13. Migration Path +## 14. Migration Path ### From V6 (sprout-namespaced) Format @@ -1022,7 +1101,7 @@ The V6 namespaced `sprout:` block format is not supported. Only the current flat --- -## 14. Open Questions / Future Work +## 15. Open Questions / Future Work ### Unresolved @@ -1052,7 +1131,7 @@ The V6 namespaced `sprout:` block format is not supported. Only the current flat --- -## 15. Planned Features +## 16. Planned Features Features required by this spec but not yet implemented. diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b3cc61ae6..55899f748 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -14,6 +14,7 @@ struct BuiltInPersona { avatar_url: Option<&'static str>, system_prompt: &'static str, name_pool: &'static [&'static str], + model: Option<&'static str>, } const SOLO_AVATAR: &str = "data:image/png;base64,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"; @@ -110,7 +111,6 @@ Surface to the user only when: - After **fixes**: what was addressed before re-review. - **Between phase updates**, stay quiet while things go well. The Autonomy section above governs when to surface a question vs. resolve it independently — default to resolving. - For longer phases, post a one-line progress update roughly every 15 minutes so the user knows you're alive. -- In a Sprout channel, check for new messages proactively (use your read tools with the `since` parameter) so you don't miss the user trying to talk to you. - **Completion** — Concise final report: - What changed and why - Files changed @@ -146,6 +146,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ "Atlas", "Ember", "Flint", "Sage", "Drift", "Quill", "Wren", "Cedar", "Pike", "Lark", "Birch", "Dune", "Fern", "Moss", "Reed", "Slate", "Ash", "Brook", "Glen", "Stone", ], + model: Some("claude-sonnet-4-20250514"), }, BuiltInPersona { id: "builtin:kit", @@ -269,7 +270,6 @@ Surface to the user only when: - **Between phase updates**, stay quiet while things go well. Default to resolving via the Autonomy ladder. - For longer phases, post a one-line progress update roughly every 15 minutes so the user knows you're alive. - `@scout` follows the @-Mention Discipline rules at the top of this prompt — only on real assignment messages (new assignment, focused fix after COMPLETE/BLOCKED, or answer to a specific blocker), with the structured fields attached. Never `@mention` Scout twice for the same active phase. -- In a Sprout channel, check for new messages proactively (use your read tools with the `since` parameter) so you don't miss the user trying to talk to you. - **Completion** — Concise final report: - What changed and why - Files changed @@ -294,6 +294,7 @@ Aim for 9/10+ on the first pass. There is no separate refactoring pass — if it Don't present work that doesn't meet this bar. No emojis. Your name is Kit. You are friendly and helpful. You are understated, but have a sense of humor."#, + model: Some("claude-sonnet-4-20250514"), }, BuiltInPersona { id: "builtin:scout", @@ -452,6 +453,7 @@ You are read-only, but you still resolve questions yourself before pinging Kit: If you're invoked outside a Sprout team channel (or by an agent that isn't Kit), apply the same protocols. Default to FULL REVIEW for completed work or PLAN REVIEW + RESEARCH for plans. Report to whoever invoked you. Your name is Scout. You are friendly and helpful. You are understated, but have a sense of humor."#, + model: Some("claude-sonnet-4-20250514"), }, ]; @@ -468,7 +470,7 @@ fn built_in_persona_records(now: &str) -> Vec { avatar_url: persona.avatar_url.map(|s| s.to_string()), system_prompt: persona.system_prompt.to_string(), provider: None, - model: None, + model: persona.model.map(|s| s.to_string()), name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, is_active: true, @@ -522,8 +524,8 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec Date: Thu, 14 May 2026 16:19:44 -0400 Subject: [PATCH 02/17] fix: address all review feedback on two-layer prompt architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated fixes from 5 independent reviewers (3 Claude specialists, Codex/GPT-5.5, Gemini) plus PLAN author feedback. Structural: refactor format_prompt() from 6 positional params to FormatPromptArgs struct — eliminates positional confusion and makes future additions zero-cost for existing call sites. Correctness: replace get_channel_history() references in [Context] hints with get_messages() (the actual MCP tool name), fix $AGENT_CWD reference in base_prompt.md (env var doesn't exist), fix [Context] description in PERSONA_PACK_SPEC to match reality. Config: migrate SPROUT_ACP_BASE_PROMPT_DISABLED from bare env::var check to proper Config field (no_base_prompt), add base_prompt_file for runtime override. Change base_prompt type from Option to Option<&'static str> to reflect compile-time constant nature. Personas: add provider: Some("claude") alongside model on all three built-in personas so the UI/runtime agree on the backend. Add comment documenting merge_personas() canonical-override semantics. Coverage: prepend [Base] to heartbeat and initial_message paths that previously bypassed format_prompt(). Add section-ordering test with full context. Fix trailing-newline triple-gap via trim_end(). --- crates/sprout-acp/README.md | 2 +- crates/sprout-acp/src/base_prompt.md | 6 +- crates/sprout-acp/src/config.rs | 22 ++ crates/sprout-acp/src/lib.rs | 15 +- crates/sprout-acp/src/pool.rs | 23 +- crates/sprout-acp/src/queue.rs | 265 +++++++++++++----- crates/sprout-persona/PERSONA_PACK_SPEC.md | 8 +- .../src-tauri/src/managed_agents/personas.rs | 9 +- 8 files changed, 264 insertions(+), 86 deletions(-) diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md index ecd66bd66..b72c2f6ad 100644 --- a/crates/sprout-acp/README.md +++ b/crates/sprout-acp/README.md @@ -237,7 +237,7 @@ Forum event kinds: 2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each. 3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel. 4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. -5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout. +5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_messages`, etc.) to interact with Sprout. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md index 852d37553..b9a0a4908 100644 --- a/crates/sprout-acp/src/base_prompt.md +++ b/crates/sprout-acp/src/base_prompt.md @@ -1,6 +1,6 @@ You are operating inside the Sprout platform — a Nostr-based messaging platform for human-agent collaboration. The sprout-acp harness bridges channel events to your session. -## MCP Tools +## MCP Tools (via `sprout-mcp`) - `get_messages(channel_id, limit=50)` — fetch recent history (max 200 per call) - `get_messages(channel_id, since=)` — fetch messages since timestamp; returns oldest-first when `since` is set without `before` @@ -18,11 +18,11 @@ You are operating inside the Sprout platform — a Nostr-based messaging platfor ## Startup Recovery -On startup or after a gap: call `get_feed()` first to surface pending mentions and action items, then call `get_messages` on your assigned channels to catch up, then check the workspace `AGENTS.md` for team context. +On startup or after a gap: call `get_feed()` first to surface pending mentions and action items, then call `get_messages` on your assigned channels to catch up, then check `AGENTS.md` for team context. Use `search()` for cross-channel keyword lookups when you need to find specific prior discussions. ## Workspace Layout -Persistent workspace at `$AGENT_CWD/` with the following directories: +Your persistent workspace is in your working directory, with the following subdirectories: - `RESEARCH/` — findings and reference material - `PLANS/` — project and task plans diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index fe2d2fd80..6bbff8fa9 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -352,6 +352,20 @@ pub struct CliArgs { )] pub no_memory: bool, + /// Disable the [Base] platform-context section prepended to every prompt. + /// When set, agents receive only the persona [System] prompt with no Sprout orientation. + #[arg(long, env = "SPROUT_ACP_NO_BASE_PROMPT")] + pub no_base_prompt: bool, + + /// Path to a custom base prompt file. Overrides the compiled-in default. + /// Mutually exclusive with --no-base-prompt. + #[arg( + long, + env = "SPROUT_ACP_BASE_PROMPT_FILE", + conflicts_with = "no_base_prompt" + )] + pub base_prompt_file: Option, + /// Desired LLM model ID. Applied to every new ACP session after creation. /// Use `sprout-acp models` to discover available model IDs. #[arg(long, env = "SPROUT_ACP_MODEL")] @@ -461,6 +475,10 @@ pub struct Config { /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, + /// Disable the [Base] platform-context section prepended to every prompt. + pub no_base_prompt: bool, + /// Path to a custom base prompt file that overrides the compiled-in default. + pub base_prompt_file: Option, } /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. @@ -798,6 +816,8 @@ impl Config { persona_env_vars, relay_observer: args.relay_observer, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), + no_base_prompt: args.no_base_prompt, + base_prompt_file: args.base_prompt_file, }; Ok(config) @@ -1161,6 +1181,8 @@ mod tests { persona_env_vars: vec![], relay_observer: false, agent_owner: None, + no_base_prompt: false, + base_prompt_file: None, } } diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index 73734b7b2..a57853837 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -1076,10 +1076,15 @@ async fn tokio_main() -> Result<()> { max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), - base_prompt: if std::env::var("SPROUT_ACP_BASE_PROMPT_DISABLED").is_ok() { + base_prompt: if config.no_base_prompt { None + } else if let Some(ref path) = config.base_prompt_file { + let content = std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!("failed to read base prompt file {}: {e}", path.display()) + }); + Some(Box::leak(content.into_boxed_str())) } else { - Some(include_str!("base_prompt.md").to_string()) + Some(include_str!("base_prompt.md")) }, heartbeat_prompt: config.heartbeat_prompt.clone(), cwd: std::env::current_dir() @@ -2319,6 +2324,10 @@ fn dispatch_heartbeat( .heartbeat_prompt .clone() .unwrap_or_else(default_heartbeat_prompt); + let prompt_text = match ctx.base_prompt { + Some(bp) => format!("[Base]\n{}\n\n{prompt_text}", bp.trim_end()), + None => prompt_text, + }; let result_tx = pool.result_tx(); let ctx_clone = Arc::clone(ctx); let agent_index = agent.index; @@ -2813,6 +2822,8 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], relay_observer: false, agent_owner: None, + no_base_prompt: false, + base_prompt_file: None, } } diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index e636e8f63..6f7c61533 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -192,7 +192,7 @@ pub struct PromptContext { pub dedup_mode: DedupMode, pub system_prompt: Option, pub heartbeat_prompt: Option, - pub base_prompt: Option, + pub base_prompt: Option<&'static str>, pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, @@ -825,11 +825,16 @@ pub async fn run_prompt_task( target: "pool::session", "sending initial_message to session {session_id} for channel {cid}" ); + // Prepend base prompt to initial_message for platform orientation. + let init_msg = match ctx.base_prompt { + Some(bp) => format!("[Base]\n{}\n\n{initial_msg}", bp.trim_end()), + None => initial_msg.to_string(), + }; let init_result = agent .acp .session_prompt_with_idle_timeout( &session_id, - initial_msg, + &init_msg, ctx.idle_timeout, ctx.max_turn_duration, ) @@ -953,12 +958,14 @@ pub async fn run_prompt_task( let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned(); crate::queue::format_prompt( b, - ctx.base_prompt.as_deref(), - ctx.system_prompt.as_deref(), - agent_core_section.as_deref(), - channel_info.as_ref(), - conversation_context.as_ref(), - profile_lookup.as_ref(), + &crate::queue::FormatPromptArgs { + base_prompt: ctx.base_prompt, + system_prompt: ctx.system_prompt.as_deref(), + agent_core: agent_core_section.as_deref(), + channel_info: channel_info.as_ref(), + conversation_context: conversation_context.as_ref(), + profile_lookup: profile_lookup.as_ref(), + }, ) } else { // Should not happen — batch is None only for heartbeats which have prompt_text. diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index 8b0452bd8..0caec667a 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -846,15 +846,15 @@ fn format_context_hints( if is_dm { let is_reply = thread_tags.root_event_id.is_some(); // DM replies use get_thread() because /messages excludes thread replies. - // DM non-replies use get_channel_history() for recent conversation. + // DM non-replies use get_messages() for recent conversation. let ctx_hint = if has_conversation_context && is_reply { "Thread context included below. Use get_thread() for full history if truncated." } else if has_conversation_context { - "Conversation context included below. Use get_channel_history() for full history if truncated." + "Conversation context included below. Use get_messages() for full history if truncated." } else if is_reply { "Use get_thread() to fetch the reply chain." } else { - "Use get_channel_history() for conversation context." + "Use get_messages() for conversation context." }; let mut s = format!( "[Context]\n\ @@ -902,7 +902,7 @@ fn format_context_hints( "[Context]\n\ Scope: channel\n\ Channel: {channel_display}\n\ - Hint: Use get_channel_history() for recent messages if needed." + Hint: Use get_messages() for recent messages if needed." ) } } @@ -942,6 +942,17 @@ fn format_conversation_context( s } +/// Arguments for [`format_prompt`] beyond the required [`FlushBatch`]. +#[derive(Default)] +pub struct FormatPromptArgs<'a> { + pub base_prompt: Option<&'a str>, + pub system_prompt: Option<&'a str>, + pub agent_core: Option<&'a str>, + pub channel_info: Option<&'a PromptChannelInfo>, + pub conversation_context: Option<&'a ConversationContext>, + pub profile_lookup: Option<&'a PromptProfileLookup>, +} + /// Format a [`FlushBatch`] into a prompt string for the agent. /// /// Produces a stable prompt with these sections (in order): @@ -950,15 +961,7 @@ fn format_conversation_context( /// 2. `[Context]` — scope, channel name, and contextual hints for the agent /// 3. `[Thread Context]` or `[Conversation Context]` — if fetched /// 4. `[Event]` / `[Sprout events]` — the triggering event(s) -pub fn format_prompt( - batch: &FlushBatch, - base_prompt: Option<&str>, - system_prompt: Option<&str>, - agent_core: Option<&str>, - channel_info: Option<&PromptChannelInfo>, - conversation_context: Option<&ConversationContext>, - profile_lookup: Option<&PromptProfileLookup>, -) -> String { +pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String { // Scope is always derived from the LAST event in the batch — that's the // one the agent is responding to. Thread/DM context is supplementary info // included alongside, not a scope override. This prevents mixed batches @@ -971,24 +974,25 @@ pub fn format_prompt( } }; let thread_tags = parse_thread_tags(&last_event.event); - let is_dm = channel_info + let is_dm = args + .channel_info .map(|ci| ci.channel_type == "dm") .unwrap_or(false); - let mut sections: Vec = Vec::with_capacity(5); + let mut sections: Vec = Vec::with_capacity(7); // 0. Base prompt (platform-level, always first). - if let Some(bp) = base_prompt { - sections.push(format!("[Base]\n{bp}")); + if let Some(bp) = args.base_prompt { + sections.push(format!("[Base]\n{}", bp.trim_end())); } // 1. System prompt. - if let Some(sp) = system_prompt { + if let Some(sp) = args.system_prompt { sections.push(format!("[System]\n{sp}")); } // 1b. NIP-AE agent core memory (rendered by `engram_fetch::build_core_section`). - if let Some(core) = agent_core { + if let Some(core) = args.agent_core { sections.push(core.to_string()); } @@ -1000,16 +1004,16 @@ pub fn format_prompt( }; sections.push(format_context_hints( batch.channel_id, - channel_info, + args.channel_info, &thread_tags, is_dm, - conversation_context.is_some(), + args.conversation_context.is_some(), triggering_event_id.as_deref(), )); // 3. Conversation context (thread or DM). - if let Some(ctx) = conversation_context { - sections.push(format_conversation_context(ctx, profile_lookup)); + if let Some(ctx) = args.conversation_context { + sections.push(format_conversation_context(ctx, args.profile_lookup)); } // 4a. Cancelled events section (cancel + re-prompt). @@ -1020,7 +1024,7 @@ pub fn format_prompt( "\n\n--- Event {} ({}) ---\n{}", i + 1, be.prompt_tag, - format_event_block(batch.channel_id, channel_info, be, profile_lookup) + format_event_block(batch.channel_id, args.channel_info, be, args.profile_lookup) )); } sections.push(s); @@ -1034,13 +1038,13 @@ pub fn format_prompt( format!( "[New request — supersedes previous]\n\n--- Event 1 ({}) ---\n{}", be.prompt_tag, - format_event_block(batch.channel_id, channel_info, be, profile_lookup) + format_event_block(batch.channel_id, args.channel_info, be, args.profile_lookup) ) } else { format!( "[Sprout event: {}]\n{}", be.prompt_tag, - format_event_block(batch.channel_id, channel_info, be, profile_lookup) + format_event_block(batch.channel_id, args.channel_info, be, args.profile_lookup) ) } } else { @@ -1058,7 +1062,7 @@ pub fn format_prompt( "\n\n--- Event {} ({}) ---\n{}", i + 1, be.prompt_tag, - format_event_block(batch.channel_id, channel_info, be, profile_lookup) + format_event_block(batch.channel_id, args.channel_info, be, args.profile_lookup) )); } s @@ -1312,7 +1316,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); // Should contain [Context] section before the event. assert!(prompt.contains("[Context]")); @@ -1408,7 +1412,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!(prompt.contains("[Context]")); assert!(prompt.contains("[Sprout events — 3 events]")); @@ -1439,12 +1443,10 @@ mod tests { let prompt = format_prompt( &batch, - None, - Some("You are a triage bot."), - None, - None, - None, - None, + &FormatPromptArgs { + system_prompt: Some("You are a triage bot."), + ..Default::default() + }, ); assert!(prompt.starts_with("[System]\nYou are a triage bot.\n\n[Context]")); } @@ -1465,7 +1467,14 @@ mod tests { cancelled_events: vec![], }; let core = "[Agent Memory — core]\nbe helpful"; - let prompt = format_prompt(&batch, None, Some("sys"), Some(core), None, None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + system_prompt: Some("sys"), + agent_core: Some(core), + ..Default::default() + }, + ); assert!( prompt.contains("[System]\nsys\n\n[Agent Memory — core]\nbe helpful"), "expected core block after [System]; got: {prompt}" @@ -1486,7 +1495,13 @@ mod tests { cancelled_events: vec![], }; let core = "[Agent Memory — core]\nbe helpful"; - let prompt = format_prompt(&batch, None, None, Some(core), None, None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + agent_core: Some(core), + ..Default::default() + }, + ); assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); } @@ -1510,22 +1525,81 @@ mod tests { // Both base_prompt and system_prompt: [Base] comes first, then [System]. let prompt = format_prompt( &batch, - Some("Platform base."), - Some("Role prompt."), - None, - None, - None, - None, + &FormatPromptArgs { + base_prompt: Some("Platform base."), + system_prompt: Some("Role prompt."), + ..Default::default() + }, ); assert!(prompt.starts_with("[Base]\nPlatform base.\n\n[System]\nRole prompt.")); // Only base_prompt (no system_prompt): [Base] comes first, then [Context]. - let prompt = format_prompt(&batch, Some("Platform base."), None, None, None, None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + base_prompt: Some("Platform base."), + ..Default::default() + }, + ); assert!(prompt.starts_with("[Base]\nPlatform base.\n\n[Context]")); // No base_prompt: no [Base] section emitted. - let prompt = format_prompt(&batch, None, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!(!prompt.contains("[Base]")); + assert!(prompt.starts_with("[Context]")); + } + + #[test] + fn test_format_prompt_base_prompt_ordering_with_full_context() { + let ch = Uuid::new_v4(); + let event = make_event("hello"); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + }; + + let ctx = ConversationContext::Thread { + messages: vec![ContextMessage { + pubkey: "npub1test".into(), + content: "prior message".into(), + timestamp: "2024-01-01T00:00:00Z".into(), + }], + total: 1, + truncated: false, + }; + + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + base_prompt: Some("Platform base."), + system_prompt: Some("Role prompt."), + conversation_context: Some(&ctx), + ..Default::default() + }, + ); + + // Verify section ordering: [Base] < [System] < [Context] < [Thread Context] + let base_pos = prompt.find("[Base]").expect("[Base] missing"); + let system_pos = prompt.find("[System]").expect("[System] missing"); + let context_pos = prompt.find("[Context]").expect("[Context] missing"); + let thread_pos = prompt + .find("[Thread Context") + .expect("[Thread Context] missing"); + + assert!(base_pos < system_pos, "[Base] must come before [System]"); + assert!( + system_pos < context_pos, + "[System] must come before [Context]" + ); + assert!( + context_pos < thread_pos, + "[Context] must come before [Thread Context]" + ); } // ── Test 12: drop mode discards in-flight channel events ───────────────── @@ -2004,7 +2078,13 @@ mod tests { channel_type: "stream".into(), }; - let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ); assert!(prompt.contains("engineering (#")); assert!(prompt.contains("Scope: channel")); } @@ -2027,7 +2107,13 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ); assert!(prompt.contains("Scope: dm")); } @@ -2053,7 +2139,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!(prompt.contains("Scope: thread")); assert!(prompt.contains("Thread root: root123")); } @@ -2096,7 +2182,13 @@ mod tests { truncated: true, }; - let prompt = format_prompt(&batch, None, None, None, Some(&ctx), None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + conversation_context: Some(&ctx), + ..Default::default() + }, + ); assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]")); assert!(prompt.contains("Let's refactor auth")); assert!(prompt.contains("Thread context included below")); @@ -2129,7 +2221,14 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + conversation_context: Some(&ctx), + ..Default::default() + }, + ); assert!(prompt.contains("Scope: dm")); assert!(prompt.contains("[Conversation Context (1 of 1 messages)]")); assert!(prompt.contains("Can you deploy?")); @@ -2181,7 +2280,14 @@ mod tests { ), ]); - let prompt = format_prompt(&batch, None, None, None, Some(&ctx), Some(&profiles)); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + conversation_context: Some(&ctx), + profile_lookup: Some(&profiles), + ..Default::default() + }, + ); assert!(prompt.contains("From: Wes (npub:")); assert!(prompt.contains( @@ -2276,13 +2382,20 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + conversation_context: Some(&ctx), + ..Default::default() + }, + ); // Scope should be "dm", not "thread". assert!( prompt.contains("Scope: dm"), "DM reply should have Scope: dm, got:\n{prompt}" ); - // Hint should point to get_thread(), not get_channel_history(). + // Hint should point to get_thread(), not get_messages(). assert!( prompt.contains("get_thread()"), "DM reply hint should mention get_thread(), got:\n{prompt}" @@ -2297,7 +2410,7 @@ mod tests { } #[test] - fn test_format_prompt_dm_non_reply_hints_get_channel_history() { + fn test_format_prompt_dm_non_reply_hints_get_messages() { let ch = Uuid::new_v4(); let event = make_event("hey there"); let batch = FlushBatch { @@ -2315,11 +2428,17 @@ mod tests { }; // No context fetched — hints only. - let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ); assert!(prompt.contains("Scope: dm")); assert!( - prompt.contains("get_channel_history()"), - "DM non-reply hint should mention get_channel_history()" + prompt.contains("get_messages()"), + "DM non-reply hint should mention get_messages()" ); assert!( !prompt.contains("get_thread()"), @@ -2342,7 +2461,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( prompt.contains(&format!("Event ID: {event_id}")), "prompt should contain the event ID" @@ -2365,7 +2484,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( prompt.contains(&format!("From: {npub} (hex: {hex})")), "prompt should contain both npub and hex" @@ -2387,7 +2506,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( prompt.contains("Tags:"), "tags should always be included, even for stream messages" @@ -2711,7 +2830,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "channel thread reply should include reply instruction with triggering event ID" @@ -2745,7 +2864,13 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "DM thread reply should include reply instruction" @@ -2766,7 +2891,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( !prompt.contains("parent_event_id"), "top-level message should NOT include reply instruction" @@ -2791,7 +2916,13 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ); assert!( !prompt.contains("parent_event_id"), "DM non-reply should NOT include reply instruction" @@ -2821,7 +2952,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); // The instruction should use the triggering event's own ID — not root or parent. assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), @@ -2864,7 +2995,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( prompt.contains(&format!("parent_event_id=\"{threaded_id}\"")), "batched prompt should use last (threaded) event's ID" @@ -2897,7 +3028,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None, None); + let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( !prompt.contains("parent_event_id"), "batched prompt where last event is top-level should NOT include reply instruction" diff --git a/crates/sprout-persona/PERSONA_PACK_SPEC.md b/crates/sprout-persona/PERSONA_PACK_SPEC.md index 5ffc961ab..f29fa3220 100644 --- a/crates/sprout-persona/PERSONA_PACK_SPEC.md +++ b/crates/sprout-persona/PERSONA_PACK_SPEC.md @@ -250,7 +250,7 @@ Each message delivered to the agent runtime includes these sections in order: [Context] - + [Thread/Conversation Context] @@ -273,9 +273,9 @@ The `[Base]` layer is compiled into sprout-acp and is **identical for every agen Pack authors do not write or configure the `[Base]` layer — it is maintained by the Sprout team and updated in sprout-acp releases. -**Disabling the base layer**: Set `SPROUT_ACP_BASE_PROMPT_DISABLED=1` in the sprout-acp process -environment to omit the `[Base]` section entirely. This is intended for testing and advanced -deployments where operators supply their own platform context. +**Disabling or customizing the base layer**: Set `SPROUT_ACP_NO_BASE_PROMPT` to omit the `[Base]` +section entirely. To replace the compiled-in default with custom content, set +`SPROUT_ACP_BASE_PROMPT_FILE` to a file path — sprout-acp reads it at startup and uses it instead. ### The `[System]` Layer diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 55899f748..e7bb0318e 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -15,6 +15,7 @@ struct BuiltInPersona { system_prompt: &'static str, name_pool: &'static [&'static str], model: Option<&'static str>, + provider: Option<&'static str>, } const SOLO_AVATAR: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAgKADAAQAAAABAAAAgAAAAABIjgR3AABAAElEQVR4AVy9V6yk6Znf91TO8eTUp3OayIkkh2GX5nKXIgULgmTD0mKvBOvGdxIMG/CFfecrAzZgwDYg2TeWLa1tyLurVeCSy12GWQ45nJme6e6ZjifHyjmXf//nO80VXKerT52qr97w5PQ+X+id116dRyxk4WjY9AjxE4/FbB6aW6/Xs+a8ZXnLWjwctzmXRMJhi0YjNhqPbTadWSwWtWQ8alc3Fy0Rj9h4Orcv9qpm4bk1Zuf2+qWXLHL1rs13vrDIpWWb7lVs1h9YvdG0dCppkSjzMbe+m2Tsm5fL9pW3tm3C2HOb2ubLdy27/arN5nOtkgXO+V9rndpw/0PrnB5ZpdKwzTe/bcWNG9Y+e26Ve39upY11K9z4hs3nUWMgHnx/HtKL4ME42i0L5cnrOfOFgs/1/5z5gr/HVnv4I2udVG393d+zVGHJTp/+2qr3P7Dl9UVLbVyxxPIrNpvNWeOMbwbfDUei1jt+aJUHn1i1MrLlzQU7OWrYv/3xQ+sNpsBvznNqgMsmE16PhlYu5swWynYS61h356ENuyHbLK7Zo5NDS8+LlkvG7KXLC+Bibp3BxO4/OwfeM4dV+GLtU9ah9Y8mY/ZgFmMdY14nkymLRMI2HI2tOqnx2dTysZyFQ1PzxQs4DhOHC0AOhRkIkIP1UJgh+RcFQZl00jLJhJVyGYtGIj7JcDS1s1qHaTU1/3N9fzzwvyOzqUUnQ4uVFy3a6VlsqeAEk4hDZKwwzLxavGaL8L1uD8LivWw+aRMANOrUbDJsMJaA+wL5MxufAcjqqdWqTVu585blV7ZtPplYLJkxAX86Gth8OvE9hYR8vu3/6cVv/tCb2jBP1qDrfEO6nmu0bZuNbDYcWCQRt0gswZhTW4SoC5fvWO2sYYOTAxtVnnL9xRzaE1+eT4c2blZt0B1ZrpCCqcLWaPRsNplaRHOJlplA88xmE0sD10gsYunlvEXbLTgtEtCmPudnyjUhfY/nBKrZOaqDfJDHfDEQW8gkwUnKkowhYoxFYv7ZVNfwcFzxvcFsYOMQcArFLALrh8N8eTaD+kUuLx68BNe+uJBWerHQZCLmkyUTEcun4nAwUkHfY5GNdt9anUGwSAelAMImQG2icmLR5WUzOD9ZTFoISs5nUzDdjHkEZQGcq1lLrz+0LtelMgA8HrYuQDze/wuIoanFMhfccv453AWnn1Rs4dortnj1Nd/sHA6MxJMWTiQggD5IGPkmgp1pHk118Tv4S2/85pU2IoToHb+M17NJnycEnICDRACO4Kit3f2ypVYvWfX4zPqHjyCCZ6wP2QTiep1dOzv4qfUa5zYBGaWFjLBsR8d19gjQtVfg+oJrtaJcOmHxlaKFRh2b9riOvSaQrE2YxgHM/wKV8HJY6VizO/T5eNsSID0DLmJI0mw6+K2NaK4pDCgcCs/INOsbOGKD6VDKxv2JYMqI+sgJgBm0GkHBfwcv9UWJ/TSIy4B8UVkiEYUYuMjF3tzF0FmtzYQBQGfMosUendctPB5ZpNWwyOqGhRstS64VLIUEiCP2Nbc2K2BoShHF8QnfYexCMQOlH1mT7076NT6fgfxH1jt8aqeH51a88pIt33yL7zDnBWKj0YTF0lmbMuccxDmr+cgMrod2//9/aO++f8Hh4kO9pRkhohnqLpbKumQRYERoIYvaxivfsOTyllVOK9Y/egIR7DBnzzrNL6xR27OzfsVimZiledYqTWs2O07kQqRALOLXfrMgPyKGWitbt3boHJqLJBH5Kat3uq52fXsAtDcYu7SdIvqHw5GL9CiIL2QhIPAiTk+nYj6+CECPALYhGyNFxvOJxfkJzyIBMfqHWog27kgQUrXN4ClKlQhKoOtjDBgT8kFcIZNAb42tlE/b2mLOUkiHwWjCgiYQAeIaQMf5TmcwsEqza7F61aIgZjYNWyobt2g+AdUnYQxEIvMLKAJIBEI7P29btzO0fD7DoudWr1XRox9Z7/SBdfcf2/FBxfKX7sCFX3mBN1YbYC4UYp3pPPOMkRp93tc+oERHqK7xF/7+X//H5MG2//otrhMMZqiSGaolksoE41xcgcZn/xVbe+XLlljYtPPjivUOPrdx/RDCXrEOon/QA9jRABmyD6KOoGAu3y8vo7yQSk1urRiiA1tszHUhS0QSqEcQyNJdSnKtYKtx+yA+hUpaW8g758seW0SyFgRXGCcJ3EUUgcoQg4u5UB38CJfJUCDJorpOiJe+D6g64EhR5zwko0ifBWIxKY5lAzEQVEY/96HEZJxFLOVcVAnIMgClCqbikIuFxwDASa1hixBK7PzE5uubFgJIucslCz8c2XiMztL1+oLmAyAjjKTz0xYSIM26kC4TjJohYq/+2GonNUsvli2/lbM+ojaVucz3Aq4UNQjFIoA+hDMbQAB59Oe0CZEhhmXFahIeuo5vXbwOAKQ/tHe/5EKiTIc9F3KxVJ4PIGyMJxGZ1jsanWPQ7iKJFjDYzuwcQzEPQXcSiHHsl8y8gKRM2ng0t8MDJJimY40B8iXPIIo4RF9IW3Ixb/PDI6RF1qK9tlXqHRsPsZ8kRvlCaCq7amp1bKQiul4EIK5fQEruIzFFHOVcEvUJkllmHDxNwYcTz8U+xxh82mkM6RVAys1pXSTABIsLCEKgYbX6A0DE4XxRlThVFn8WcVVv9WypnLGFQtLF/giAa4GNzsjHc66GKAQoIfio2rBEv2fnuw+s0jgEcXDqQsqymcAWkDaRltLMWvTZScN63QEUjxjn0UeSVPsnNktMLLECYsPo+2jGhoMzG/aP4HYAHMbiHR4xQoMxsAMG59bv/tIGwyfgnrHDGEayI3iGQvy+eAbEp7XqPRix+xCVVmf7I8bAuOX96ezQ6uf/zgb9RxDtqbWbH+CING3Uf2Ld9ieW3QBixYmdt4+RXjUbtUcgKu1rf/703J7vVLHGQTkwDWhsZv1Qy6Zx7J1rq5ao121pbQWDE5CDQcF/7hwoz0t+Gr9hpkYfJMaAGe9lklEIIGHL5Zy1gFMK0Z9CTUsKyC4Q8zpz812NN4OpZdSH3TYTpCFAzSFRIU4L+CH4e+rULpqfBboFkRJh4FRSwMAKRwdtL8DVUNoTjJveGISAOBlBpokiQqS4W65IzFq9vlVbbasPD22xzPfqfSvi0rTaQ4AdtgnAnoOgOQSjZ63Zssc7h7iFl+zR0Y7T4vp6Fis5cGUmeB6D2TOs7KfsDrEZx7tIb9iox7VwSyg9A/E7luznLZ56CYMHbp20ASRubHxREPn3Hg5uJFcDAjqAg9r8fuIEoXVFJEXswEJjJBFG1LB33yYYayJZSStxaJO91QZDCG5iowZEOohZ6Uradp4e2x7Il/ScID3CE1bA9WMw3Z91LLVyzbIQSryLp3Nesbjc1onc6SHzA48w6geQTgRbCFxIaw4nNu9JzYbtq+sF5/bnhxA9f6fBT7cf9vdk3om5pcJmzD1DAqD5nTBmwjs70IjBK4hAbwRkgAeOwcAK/J0YOl/UJCszDdV1+yOXAqVc3B4ftq0zUjxA1+hyRmDMuSZjA3pL44gY9s6rNs1M3ciZj3qWieVtsoZoPZ3b2fDYQtE4SMDTCGXxY6PWgAheunvZDs9P4fIhfnTHSui6YmmOuP05vi3XwhWKW8hVbA+QAgBBLuUkBfdC3ZNWzZKjX2Kc8f48ZbEwdoidaFU8fHX8ntvYMGDDcPR4aEMZrRC7MwbXxJcR1+xhMsS9mjWsg33iSGTflWrPOXsCd4+GMAaM0OvB/RkQg4ocIZq7EL+YQkzmIGW+SFzTzq24QCyBGEASKTD9/NRikwQeDuo0OmV/rBn1oVjJ2I3qwGPTGGEY7wwpU4eB1heSdnyG94FaDqRzsL4oFDCJECcAlRPhE6kZvVBf2ptiBFE3FNhkmAVqdfpRgEe+YgSKE8XgMPrCZVjIADzBSFtAb00RJ4f1sYsmAVFCRQiYsjHwzZjECTQi7+nzKc+RxkZPiiLDrb5lcH1M8YFZlIXODK1p0Rk+Klw878ysgepYRed/frTDmACCoEm9NrAMRuhggF7ETYvHJ9YH6AoehRD1MqxCiJ4IxKR5kgn0ZmoVwoDA4GHxsR6BEBC1Bq9D8xzz4kaOWtYhAJYm1tGH8FpaK7BRwEYSTd+TqK3VuqggdgVyVtdSdnTQA9lcK07g3xiVGEL9rSzlrYlt1O62EakhdPPEMqtly7aBXatlmY1l7KIeSDJ7uncG/KV2U1h9+BphxgOJIpaQxzVkLwEbwZk5np327eoqqriUQg0MMMixIUS8rFGeW5hBtfYBHtEU2MRmkrBSMWgwNhMdQ+36S3paPqMuHkMss8SUi0E/SJxBDAK+ACu9JBWwUExZAyu3A1e42J8hH7VvIZuxHAiKMTKe3nfyYJ4Evn2hELWzacUKuWVLsrE+Ui+JZIm0QDxSQIGOKVTfbU/s5KBuubUMwJ/CWYo+duF4PkPn5fOwEWtpNeUrs+FxnKBLFoNoYKXEohOvVFw8jgGI6SPjNHjwJity15f59ZhLJko/zuNYySUn1pCItcn6FE9gKo01heslVnvEPEb9OXPhbTDG2Rl2ypD1sR65aArGHO2e2eP7BxB8GARIRcLZYjTmTsFAC8myJYBptNa0EIS0e1rH7x/4+PEpNgwTRuKydfrg5WLtcLVgLHjCY3bcGKBep7aCPdbYrYH8MAQfs458fN9mINOJOTpjQxK+NpHIBILCbQTJLMwRJVgIDkzWCw9sCctd4nA46Vqt0+birDVakgJR54DHJ7hIDBjliUziy7IyNYY0FZTKxiX6FeCRzavhRRgzWadZNibig6sylzYs3mTsHnoawMlQEqdxGcGeDgQyRiVMLcHg2Tw6Ds5BQmJsiZPhdCAR6oWg/jVbWF5zBMSTSQIxGJ7pFDoft+cCGL5BEaUQr18gbEa0TAaWEwTjSZdOO+hZ3KuNpU07Oz0GMXUpTMuFZNnH7aR1aOF+wgqolOaojUoCSW55459DFIPI0HYO4HjpbeYSwvRPkblkEk8chkoTtEp0x4R5Uf9IsGeHVZhJ0hL08D1FZ0X4EXARiiNpYHmHH/AOad3y67GbDqoDu3sp72F5hZnloU3gYrnmmRSExLX7nYrNCS3ncMWj4MTJgnmj8ShhU6faIGTohBAFCKxDFnqJDe60u5bMAng4sIn1v7VecnF91ryIRvnewAKiUoCVyeSWLFQvWGtTorgwon3Ehs8rfVst5SyHwdNu9yy7soDPjAgknNnrKZoIt0LwM0SW9GnnvGfTGLoMkSru0uKHGEJ6jelhkUHClgqrtrC6CiIxVvFYWIgDL8ECBCgB1dEgSuCfnqzIOtUqoj7LIFo/7/G+EJYmTh7CRUsSWh4T1k72ZHmHiXuUbDTvYvQ2gE/JisUyamJkn+x8bk28HBG33D3p/X6DIBKv5Y7pNwsBmSA+SzgdF3daw80DZop9fP78BMKR+gg8FDGWSCGMZArPE6wFwxSCj4cJkU8wmBkvHEVNQKzPz/p2d6uAfROyfQzyDHAswLwD7JFBH+sGNYaCdGku5OshEGhJ0bEsBP15AZhAe/AW70mai9uThA0HoZ69vL3tXN1ode0LxE212bcEnCai0YAXbIYuwyXBAJqjHuZREKaNiwiYPDzliWXbrA2tHRnzPTgChM2IlyfqcfcuXISCEEkAeRtDjKQperDPZk5PQE6aTSA5QikZmqgodCXGuVX2ToEK/wCEfnpIrU69gS688HvZkBAcApES41O4ZNjtOtKicUUN2QKYCqQCZAz3NXEH20gSuYjJRMZOe4c2yNUxCLEkWNtR89wJEX5lHlSXpCe2Swedj9L1UPCL6KjCwhkkktzf0nLBpudEONnnAX780XnLYaQ8iLuKLkW1VnaCXZKM9602xYtBTc1lmxGW1oIVpt497tuvv5hbuZCwW1c3CcjNeK/lgTrZVRMxOGyZkFeh19qj1CHAiMpSFTmJomTMiFelAiaIqkq9bYP42DLhNG5Z1fpLfVvOlshaJe2X98nCVdtWXiCSRURPiwlj2ioJEk2OrJTNoIegUCZmVp5QG5sduxEE8kiO3H9yZFeWspYlYFO4e9MGRAzTUG0fY0bXu9GF5TrGutYwkTIEmcJGaWPNdqI2Kg2JnwujEzt9dsr8jItolRR1G4TfY2yTVDwVECnAdApgVcFDf6O7W/u+f73n4lqw0NOJQWFu6XaWADHMEujvyzNrH8xRi2du6I2jQ8tdx0MahMkZRK3VIBA04Lp5ElgygcQpY0kSxtMxK1xZsQjx/AgSbb/esmZFtlfgMvvG+YoeTsZaM5IznUpZLQTB8H6cOIDgM5sSJyFUPIQxMkiVW1cX8NAIFrVwVZEmfSR2u4cRi6EsKZ8KyfXgoSWJu/kdHQ6HDBSIVU0oPRaWDJMRhkhTwkA+rMTBQQ2XKpS0hUjaXrm+Yk+PSACBNIm1OJHCKZmzKeIpC6JGfSgNHYqn4aIY+9LmErMxiA0y6zQn1jojJczY213mkTV8aclmTTJvQ6WbsSdYoPza6IDEEDbmODm2cQojpjO33BQgQCwziGNQRd8pyMMaX+BYe9E/cZOMHbmLQqoUgQ/MH7xiUfyv15pMry++xx8azt9yr8YFMn+D2E6NxShMqyl4RvPAqoORiiTqoq6GrB3wQbOBPaNFzeDEJMSR2ShhW2Bf4AFl02nbPXlikRnBNKZGYUp5ulvJkhk88KJGxA5KORl3GMiTAfZoDkYbgGB5FmO7vpazl64tOsHunbSRKB1stT4MiEvLvGO8JAZGkkDsfF+Gqhbua+9LXCvurDfwpxNrqzY/PUXCssk4gIZ7lK+PY2/tnio1G7Xr00W3OvMkOdro4iZEUCxmWSAUD9ASjDMbJhGJkgrMxaa8jgAOwOiwOFzUo2QgB1W3odgRIc/p432bpPlsOQfHQ7kkTmTRiiPlEQ2SBEZEFB3EFsaQAxnfPoIqGHVBVhQdp/kkKQC4qyVtmP0NRJRxcu1CquCqnTuy9U7wnkfd9KFWK87nB6pA30N0jDFHnIcmQfZzegSyRHySDIwjidTGHx9HphAbKgAjLIzdERrDFCCPJfqccUK1ydWSdT4/xJbApmC260trtndGDIJ55NpJxMs/F0Mp9qDx5Q2Ae+wRYgTAezLqAqMRyA9sgTyZU4moR6jlRzsN6w5GjtwUHlFqYZXw+UPsJEUOkzAWxCBc8+xBDNFhCDGKeRuVfuDNMPpyjGGSWdqCgvBteL2xuoir0aXYokfgoUNMDG4DSH0Q56FGFilJEF0g8OP2F1GvWQIjLuBiojCCrMNcNsCIhWdAVgJdphj27mHNbl9bsk4FfZxnHSAwwWZTZMkIVBAUGdgAuyEMJDVUOpYBICmIGgAA8ATGapSx5KpK2orY9OxPOq6DFzO4hIwjFSfhpty9Q0jk4O4VANECdQ0EFDxEtgRL2Bv/rNqtYlFDiRCfxOkMScbUwB3fmuBNCMkZWR3Z/m4Xz4CaBJgmRUZPOl05EqW5U0i4IVnBtCQCk9z7/MhOq6StIeQE6nPAWCyBfQBEiMFz+URR57jjej+Bap1jEHYGxCnabJSHfP5z6gwePa+TOVXOgyISsq0y7jt4YR0M3MkJHhRYDvFeLEfwzW0aszbeXTQFeykwIwNBk4gqZSAkCyRrWEendmAD9HoXPb1QzNt6oWg1vIJ7zxrWJA4gYErfsk9rtTqWKUF9fFERvTDifYYOlt/qcQbBXa4hoc7MEu/VAR6A2j9p2jqcn+J1h+DKnAiYjDohVNCHDNApGIm4NTHWliLlqyzbCAJTokW1BVFFtZAYMjYjbvRhF5ydkjFbZU1l5yaPUUAYvs8+Rhrqz7Hr6AfR4nzW+MIOEDAEZmSQJSZpy5eyrB8ChvvknnmSCqkkQyuN/VGpkW2HQ8uRjOV4DlCBKgARt80Q/y4tyHGUy3m7/wgbqo445rMJVD0UoWkdomCeMkAVXxGhiDzHSJNkkpDzOQm5JD4CBudsCtHzlbMmXggEcGkxaZfw0DRPm2jliPjAsE0UlX1mo0RAtQ5gox0N0TFTpCYEBLfCXRMAi7RmgyAVua3QrnLrmFC2226ib4dwXtTWyVrJqv70KUkXvhvwTkAE8ijmoT5cu8D72MVCoDQbY4uo3NJQsIUNkmRD94kAFJCY2pPdqt0gqxalVErhVBHUHB05R/bFCPwUsQVioRHx9hMyZ4uWzeYxcGpY3LiR26suAZQ5m+CyNZqnJKValIVtW355hQIUVJwqZPAmQhKBXBdeWrQpHgDQcgQJKJrUrWNHBkvnIaIQ4UyqFKf0O7ZcpPSNKKCCZiK+PuJWLukEQ23O6yl5hFGa8fNFbBn8dGICEURFBJXWJTBUyqTt4LRhJxiBQvBEwSOZ3sK7CBAkTYGjvA63zSTVIOohBmaWoGmdoFEGKZlfJvGGR6RwEF+z58dte+fOEhw9s3O8tB6SW/n/Xr+pVAlMg4cVbIjvwF+oNdVsRHvDPparom8YSuI1GWo8xdXxPFzZTJPHz1ljfAZHkwxKwb0kOkSv/tBoDCkdGopMSGxQ+lRDXINwicVQFLsAMe/c54Dle4R9+01EJL79FK8gAeefk/6Ud6Eolnx/EZZzhDhbBiZuYLPbhFjhPkkc2S1YwzOJRye2uHXGXWoNsV+QVqXisq0sb/omQ9TPyQKaY9QKSeLICSI5lM4wTsD1ms9L314gX/vSFi/WnMsV7Ay38qh6ZiuMDbrcwJVejiKNBnBshkBZpI67iJEcxR6aYROIgMfsf47ol2ssibBDXaQeInztUVM5k/BbhnOIPSuEzQb9U7mtqo8oYFzHsJ865I3KMERpBYv/HBiO5H3M7WRWtTYqpd+VfQLPsvYORJvGcBdziFCkCoECthHGNsIAewpRI8XKKtJcFCNqxo5sVqlY6uWX0AH4wIiQUiJnvaiMlVEQSdJgMvD4nrtcLD5OAkbJmt1zIneDhi0vl9kM1A31oQgdwNCFTclWhSTCWJEsbP5h+OBlHDfRX0X/240yVAr5YJul8OOlTuYDcv1E0QiXzsnFR1m7QDRWwQjEob2slZctkV1hNYxdr/FbOBSIFZTyxfp7IcR4aCRC0x78f0f2xeTBL8aQOuCXX7daWsFgO7DD6oltLKxrBiTnCGMKQ1Pcrlo+uH3E74ZCuhC65h6D9CluWbwUtb39qqsOjw0wL9sGDiCR1y+sc3G+kO+ZUbCk6qbz06qVF0OWL0asQg6kiXmWX4rYypYCXkT48iTpMD5z44ItUsDbY13PJ5Sk9aK2nMQABtbap4h8xGeS+iFsk0hmMf1fI22gBix99HaIejzl/2NUkkaRAFH8+TzunaqB+qiBGK5Yiujgwz0MDlSCxJPUSAjzu7Q8sfUNFV5E7fQIJIFtJU1gSRZAKEJkKXhKHSg6dwFcSRNoTx/BLRAkfyja7Z4Ja/OKYSTDOEKtAahIYgj1WecQcT+Y9Z07VGS6sbSCSpGOE7KFWAYV6i6IzJHp7/iHwISZRZAAwkPQAF5BEyFCNouW9+LhL3kjnUjbMwpBBzMM4n7Dzrrn1sAoaw3bBhlIMFAT0CS3iH9O/hgFJlkBXZCzwDVrthC9rAmacANVcyveIe9BQS9FEl8Eg3zpfLtO4esAVUFZIkUyMUrQBKMIqXCIa6g98roVsteIhL50heJbcFKdd+zxPjUGW69aVlVNEAByWZYkkrQC402tFCvBzFDoAG6QCzWY9wnEIFohBk+HUg1L5sbSlFjHiXj1T1N2QOHCpRwU5cALBJgEWTwzckQd7U/seI9ADRwrJDUbcyJ9XAfCFV+QZ6BaQi1bWa8JXCtTYcxTyJXlOyC0GhQzch3vNxuoI9Y0LalAk0ARkS6pqgjjxvLMPgBwhKUbh/tOSHzFiUcE5FSFpa/XLySVEO8IBynS8frbr+N7QnRAKBpF32GlfDd4n894u9tDtCLStQa3c/gthKrgIwpocpZBygHPVM9Omux3zN/SydgfstpH0vGM6ETO74h0MePK4Bs7c0gy8heDdqiEkpsspDaqZB3XZZthvLpngDtMXGJIzCXBAFc2ihTppOwEgnz4kOqiaQaJjDEP7pR2CQHbYbvBV6lXIOk1gqiiW+kVqw1b1qOKNoyVqZhxGo9AoJBeCgnQ1OZF2+f21bur9sOHGDDRGsimsBDx5G4gnJkjRz/soPvI9sk6TcmS5yFqTqbSpGZRHSBCXgOmgYt8lS0leab4jipYhORFLG0h5Cl6MktMWyQm4Et8jdhsogjY2nDuCGJhPCLNpIUxwhzJgY4T0TjC+c0//89pgXEDcc97vHZrX59rAn/wQojALnEJ4HNLSohIAokgJE0wVNOKeSDEAkISQZE7QfM0MK7CE0q8J3gpSD65niJwZQploYtojgmAjeD0AWpxKEMSBA8xfBVKVriYjxxuM/AwoMg0QSJIhrkykdixqAKijXUki0Csa1lnlkxZMUfiB4B8drZvo/rMbqwsEUEkcyk4AJQQex6O21yPSmLt8j5IlFHjz9GPKYvQG61pw3IzfEZRN6NHRIkXonubipxb1RX7pHqMsZfD+kW3E4ZNZGV0pax6QsSONJ1nF1mdRL1yBVGyXjA6C2BWJnegaWw2TERYMA+AzOaFoAJhTaWNxTHyRkREc0Xe9B1cSIcQr1/8KFYhQuHL/h4X+m99z6OaYls+09iaKxDxAFqIRewK/xrLX+gCrnLpoT3oen3K+3rqu3I1db2rEF3B+5Iumq/VmFqWNbpRDJdCSy7VoG2CPyqfk5RD1cguQPRLagzZp4iBt52QRGQyBFV/EceInAG3CAEWQkVIwyEHUohxdBWxFY5k3MmbCjyxB+cHtr/TsluL67ibKZswhpLlYooJeBxFBngewZ4VM/CiUIVJy7OC1am9n1P+1KJ0KhNHdAAwPfnniGgRdtyGqr6gsieF6O1j7CUyLTwDkEWEEHj6xpmP3wIdG2JXIYIcXdyyMLaERF0UeyFMYcmU6FqbOLa4P8lC+ySP+oMKJWNZagVSFFEEuXG3zhkrOSLngAUsq19FKoK9UqSaV6gXhQvF4ojgx3HDWgC4uEdrIokSzeTJbmZwJSnFIl2q6liJeiFXEbYB8fMusY4OJexjZSdlD4FAr3riOtVFBCD0JThhaL72GRwG9vLYHtC7Iw22QpJzCgdppnMTKqZRpE5r9Hg9xqOsfFnnQ1SDB5n4TNHQOEkvLVkMxVtOZCkM4vLyDBtLfn6a7/IRhKcxTjG8P3qyb2uUvF3dWKDmUIhDseo3zyl5HaWJ5WDoLdkeQVUwFC2RnhziAfDpMDa0DpmnDFeJcsRFovBSgUAMom+ztGDt2qmlS4gtrNV2jRAlSNSgrNcXq0kF1AnAa2A3qKo4BgXqTMEYOZZG8hQJjSpClieZJPHVINUszqpwyiiHFNDuFEXTYwghUUHmHNXrUKcPJcnwxDFANMpgvNCpzPlC/IvTBgAnUSzY8rV1u3Xtii1TeJnDR5f6ErkgU0AGUBR7O1owsECfpNUItdhqNTmYcmR7z47tZP/YRuQsqC5wgtDKLuicV9gzvM8uMRCRSBD3TLLfDdFgTbsUt+g7Ol+hKqHzWs8PeWQ9JE5VE3WGcimFTNnIKr0bKtpKYG3OmNCRD9cnfJEvk6zzCmCYQWtH9O/0jm1Qmdnbl9aIokZRjdgOUK4k1gyGGWIw95tEA8ClQtSrREiVxeQf7ggiP0ElbR9feo5x1Y3AAUiE5DwPYqnU4fMz8tcaeHWxYPdOqMRFBbSacnW0LbyDHGK1K2MHEMKZbs0DW+WvywtXbNg6cSJxjoZAVFCaxrqPq9IY926MD1yiUkbnCORWSVyO4MoBQBDDRzFewsQOehhDLr4gggwxblXm+HErxhRKhxKvuJ5bd2/Yl7/yql25ss36kSgkkc4wyp4cK/IGFxOq1UmiiOwZdKw/pPKI8UfQ5ayMWEXSNq4u2q27r6B/O/bs6Y798hcPbOeLXUsQIdUexM2uTqhfEKD7yojijcR4HacwWAEYIVOnd9ZX8pTO96xIDX+XeEcSDvUDNqzbyZHx+tgDYVLPvdYZsENjKwUskxFRt7/Lb9a4sEpqebFr53tIUpC5djluZ+d9u0SMYnsrb2eVno8nHAjenTBl9bjU2l4cL0oqRcfD0BwB1ypP7OFVKFeGXZPkRqLJJqGs5RLxAHE0lCSKUlJhPk5i9FHHB7BAsRPBCE7Un9LxvmAIQ+JZ1lKP4EwimYUzlOQB2HymQydMChADr0Nl5qzVdWELy15cKomh+LuuH1MilqJAM4qxqnQrTEEJtzgWxHOJOLiDgbZ6+7r97t94z7bxXk5QU7/6DEARMRxg+YbBSJKgTipLkIsafMUV5GZGmEuDeL5c1jJiuku4+Ijcx5D4RISUagadW8B1+u1vv2MHL9+wD//qvjV3D4j5gyQ2Ks4NxRQEw4biJ4ItFUaaSjIopZ2HuyX2F1QM4vCW4QsPAgdBTIaek4FC6Ugvcb2PKaNAD4DTayv0TPh7MWLFRXIMSMN+M0YlFPbH6czevEnom+RQmBpDqS02Zq0ILusAwxNGKkTzMDpJIUVtgalUsj/ckAAICTcAMb74XSX5E0+d2Nkxp3iIui2WGZhrIoRVZyMCMixexDJApGuwBMfG+m7CSqQyLIMLMfLqB92WZRdJ90KFcYk0PlT4WckeWf/6fop4eZX6PpU1jaF6ZnPAptDRIyRCBFujw0nbOYEkEYoMLfnRmkfrJ7VhX/3u1+33vvWm7R127I9/fIifjosWxTtJ5Sgbp6w8j/4vUCou41QhYsYOEeH0vANLlrEZ0nrYlwyxOUEVxUhPDo7tkKod1eJHmGujlLc3v/lNe/LkuT34+S9xn3vER/gOdYmCiSQUEwMnCJTClVGL5Az2TgyOz+joGkQmZpJ75xa6CIbvKDAWQeoMsD0cfvwndRpIWdbJ2AgJ1B7u8bm8J8LC22IWSssI229vlJ24RFRiWIXLG0hynVIqhPO2kCpZk7MOKQX8gB9T6r9gArloGRCt+HWpkLECxZHnlGKfN3fQayCOBcsQUrBFIQLWCodypo0IhRsa0kUgTkjXUxtwOgAw0qchCAea9oWJOD16hojz67hQVrTKzuTnB6FjvRc8BagoxSBRQqASY0J+4IIR4seC7mBovv7t37avvf26/ej9E/uTD5r2pIphZVT5wvVx1hgD6THEvXMV4/kiNY4ylEgOmALEILKRZCMQp+cLT6FeO7G7r79mi2tXOClctP1a1B49HdvdK9fsP/5737HQ4rIf2SaR6xuXClBQaUgNZSwDuSMlRuy1j8uqmj3N46wBbDwkLGACCO3XC2OVtxARvgAmv/Wjf4KqXGKV2A0blLMNcuxhTllcgUJZbCfGAMqokoE9f/Ihdgwlfai7hRSHTzUr4+qshsZzG8DjzgGqQC4mDjp8Rv3+ar6M6IjZOfHl+jJh1Qg1d8TgddRZCxXhaDYZeBHi4EPEvFv9Wqx/po91IdcjzoZYL6q+7XGAgigp3wcwWLVtIlo9cQek3UVUgk+vb+PbfB9JwWQS06Muy5U6CMsh4oeh9XkTkfgffPcdW6OW4YcfVuzpKZhE3MrFVQZsRChb4dgOyZ96teIEIN2orGEcAtbv8tISolNWtUiTOaGGEdETWdhPHz+2y9evg6iAQ6NwnZIzexSv7H/Ysa9cz9g/+IPv2j/7Fz+ycbVGDCSQcDqDryBoAtEeSUFgTYJDEFWlQSZS0o0N6EcEofMDmnPstpMid6gS2FMVx0TRWIekEnuH+H3tRG2n4MlL7RhDj3Kegk+JB/4kNGZn43OOzxOe7sub4IvYJEpAyZ4L8CMVwJgyLkQaEYwY1Zg39zAGSRbIgLmytG513KXPd+7Zra1ju126SsJGPihf4J8aRCiMKXWgqmE0LYtnwS5buIRJNRmg80xdAbcrQko4gaFZzHJ6Buu/CNWq9rBOjeE5p2rE3b4LTcA/ls6Y4BRRLD3mbhG7lMhU/dvdt161tZUN+9WjnrUpp9YxtSF5BOJ1LlWktqIYhar8DYNwHfUOQ7QkAyCSqX36wV9RTp63PBwkAMX4TEjOEeuQbSC7IJUpWgcPQOpCMrgPQiK1fZvjDb0/ewUmKNgf/P3v2P/yP/9LbAX2CosLd7KF+iphA5Y9oqJBwAduJOAlPR8Jd4GPVGBAeFPF8bHDotgbWjcoUWmh719jCpYehCJ0OiXOIHrt4rbKtStQZCI7QrO2Y1WrdND7vZht51fsWfPQ4/9JGFz1GDIA9cANlApgIDYVxeC7fa3AaZ+qNUYck8qMKTRM43cukr9O2vsPD6zwBjo0SpIHOCiAIa6VHtJJYb0nl0bdKeTOBHpVBMAGWHySI9ZDfFUduBxL70IU+l690fWcQZbDHnkOPPbq+MkgUZE+cTxfRw+TTGEuUe8YhCj2L7UwxbL+zm+/bf/258f2HBeoyWmidrdDTuIS3+PgCMbcAG7iDKrNkCIzES/xhUwkZ8tb1yg342xDs07EjAOvHZ0DBLCI3yk6bqpTQpxJjCFJGmfHVsKGSSEldA274lgbNZKEVhtc9+ETxCzxha988zX7yb9+30rU/omvtPgeTFjIiClw33BvBbMTooFtvADZMD2IuosEVDJHnBsBTnHcbZ2sEvFLbeq6AWtSVk8ei0sHCEgng1Up3CYDm7gcQcpRODusYvhV7ORwYldf+jLVLPsWJWOogypaRIZ8hgSCHnIieVNRMek7ypMp9MhyQYdqkTZ6n3nJYSft5Uurdn9v3369tGMvLyHyWUgIBOnQ5gy9KoTIsuVduIE4M+LMKRjAS+c4VZIlyyUUc+AQI79l9OU40KhCDy2jjUXbEjVDlBJZflgDJIsA9JBkkaRwccb1qiZav3OTgpKBPaTAtAeye3xfgG2k4VgyhJ1lzjf8zusWBjni7ihE0KdNy+nHu1ZY3GDrGLsnx7bz8L6LVi0kaM5AAQZEkaNtSwapcdaiUcVjkKi6Aoy0MsTQShSstXrHVuHUU1y7f/qDiv3tryxYjizoiBPRMvJYLOVjcCnp3CgldDrsGsf01p5lJ4xw+SK4t3J5hZ8R0nFEnFZ7VVhd31dxiFzkuCSCkE98oSe4A4M0VcYyWM9OkI9IxCnG4KTQttYejSkSS0QNV+2g/QSCglkII69T3j9HCj+qoCYBrKsAvZJrJnEuI0/Fh8VZHhFC1Q/WbZkJFLgYjFbs4WccyX57j3w3VitWrur+ZNH2ISAFkyAjKBf3hrHkHYB9H1s6TXQwUVk4yAz4WgYQewRxUiMxAK0NjkjTypVxTgMAQeSK1fJPEkBZS9kUFNQQB5jYD3/ykNNlfTumGYWOaKezWVvbRjJR0x+akcf4tM9a0edIE3FVer5qy3cvMR72BpmyLIGt6196m2gh0oKIZbdJXR2p5DbrH+GP7z546MauDnGm8BwmIOvGzZsewTw9OWEMjrmzp3OKPJ7txezL79yyH/zRz7DKCd/CQSpwCVEwms4R06CmUdzbgVDBsRuGMobFMIou6il4BpnOwI1W2ZtEt6qe9HkH91TZ2zyMKkLpQgxxxixS31AdH9pnu4c266bs7sYa6gKDHZNDGgbQkfMJop1uSAvv0lPwlgNWAYlFasvPqXotc8a+3W7bTvPINifXvCLnZSqBzz9p2DFn96/czNm9X+GaIQl0+mQIgDsD3EbUAcE1hlS0jDQlhpS8CrblakZiMkLES0ZdppBDrHLSpteAigk0UdtWJWU6wKgjoc2G4RQ2qt45MsBkMElvslePnW9eWaOx1Nz++P3POGK9jkuS4IzeOnpeYWYoHFKLA/Dho6qrIOlQxeBBhxOTDD6pqhqnclXmBZg8d5EGkAX0Px8jf7Har9ygEqdGidk510EwpMl7EFwCY3ipXLSPPvyVLa6suuTsY51fIxL3p4zbJcopLpXKIgiBTUXlMPWNTdzYVcrrxHAq2+7gUk6OTyECPCVqHVUQExzmJA/AfrO4bAmQPxKjiTnkwkp6QDRqpzPgvRkqta8agPNjO3gysjdWLiFhydcgRZbpbdDhvKMWU2NTfSKRM9LSvn9xoqxKlXNBBugZUR3uIHpvg/TWYeXEPn/0oRXXNmyB+Pz1FZJBRzu2fQ0RhYGnRkWoLsKbuFfoRiU3FOd3NodLI9QGTFTlwhxqlqDfGl+GVRhiUZFicgrh8P0uwB4dyV3Cs0BEirAUKcNE8uyhOEf2RKvbxzoO0T5lZr/4xRcQXNyyEIzIfIC8lbgdHBy6SpLbl0CUy/eWkafMpOZ6EXKVf/3K22/CADEOq1BRA+c3eVZB+MAPc049WqluJSVOM6kti9QegtQ+fP+vbHVj08u8O9QfRpmn0Vhz1bW0VOSgypl7TJJgEr+1E2oHCaePk4RkMVbz2DzDkTwEWe+qEnJuhOCV6RMMgRmIT4Bs2VtCuCqpJUp7EKIkgQja3UWYpTo+9vDywmjRltIljqdBfDBQirORCSqzBkTpFHFlSCQlURNe8FLMIBcD7gB4QoyLZwC9RNBkyjn0PTaXT5PyTV+zzZWCfXSo8+uUWnM8vNWSOUTAAd2mGgBp7D7HvJJQqXSpGjW5T0/WUOpCRoy4ncsshHiSnRBjw9LtHhn00ST2A2DIQ5FTIET5uvhDoYMhYngdt+fapQ17tl+zE1q0SJRvXr1ly6scvABoCTg5AcAE0Bel1jKu4hCXDLvExdOTQYoLMKXONaiOQUfVTg+o/jk4srOjAzvefY6R2HaxX+A4mPZ37eZtLPC+E/10g2Z6xOZPDk7sx+9PbXkhayc7pxTbBgwmVdchb499atfvxO2AiqY+BlspUrAQQTIAf4F+4AKCfa/8L1exJ/UFZJU/EcP1UVuyAfjI7SvZD+sbUWITZ1bbndtbN7etuJL2I3hS7RL9YfImCv8mYMo+7quKV6Kchg7cQAaS9pGlrS4TYQo79SjQxSOaw/dPbCJafo0dgF7Jb1OPlrM2DRGy9PlpcXpWgSFJEKbyYhJF+qSbhixW1ry4XjjvTRrYFrJu0cWaQMCRZS+ZzkNZQb2ngkgvowLTIxDuBSRcouLQPvpSojwBJ6tvniqK47kFu/alW5RLFZlPfYrIyQOkPqXsDAgxwPEQgR+XRjSLOLQm6RL9SALKYFNWNInUyXEsPEelsc46FIp37LVX75KGrVGJc267T59CbCf26ItHtrhYYlO4m+wx9w65js8OrLpzZh8hlIpU7qocTmpLEs+lnuDMM0HS7SpqZIfKnAp9jGKxgn8gY1lxC/12iGCjBEjGeIWZFFjS32IscQWX+iMs9/1mHGJt2uXitt26sUJGF7zE8GRE2FHUMLGXfIyqUsSR1wcy1hiJgARgIK7pqkYcsaQCRskAVQRtFLFuQVhmZdMeHz3H3aF3TbaKJEjhauATy0LHg9CPA/JiRTrUIK5qc+JHhRS6bobh1qH9WRPi2FhesKQHOgA86kKSQXZIDBdNel9Gk9yeVqePKggaUcpPnlPe2sFgVHCmRswg3pjZe1//ih2cd4j30dASaVOgbD2TZ3xKaNNUDsdVjKImiehR6c2oAj+s7UUDCEFahZoy5Or1pp2cnJFwoeQL8dqhO9kqZVZLtGBRa5wVDrGWSwWrnBFsghA+/+IJBbA0XQSQvX/3wI96RVaz9jkc9t31ZY6ctZAmikUgiiW2hDwgLvW6tbBE/KNo93Z27bR1DFNw6ATY/CapxbqkBoRwyQbkM4QRBK5mslcc+RAWw5Y4npcrUIn1JGKvfmXTwpyn6HGeM3xWhagJlIGr+UCtfYiKAl8Z6AU8GJ38RgJIzKBfyH5R8+v+qMYW1+lMu0ymciltr6LrmpRkP0fMYH4iti5Ku0VhLFJEoIeHlSEKEakm0vte1EGVUCLEARNO1ZZ7pH8Rzd4kgnnUG0ClVSmQ7i4jRCPRJ5Wio9QJ9/8hSpcUABujQxmz63TVKJNY+d13r4DkLESM56H+dwRTRMQCoDhfBOnjAHipA3G/4uRa64ueB9KleVTexsaGV+/UcOMO9/fh+Gc8n3hofAGDT9+VlLh646Y1q/QKIoK4v3dobYo2k7haIbqd5C7nrLqKzn1MgIw1yJATQbt7meU3p45F6At4GDco3BiQcq7QU3AcoSqLgIUaWularc8lB3BlN865wo3cbTGc3pVLXKTfgk4oLxO5XaeoFoVCKbxCwkQfcQuPOeNZSFNyz5hav3CigzRz1qD9u6U+RUf3qQkMQTESiZIvQkBIUSmAtUyRxqhWsIdPd+3qraKNT+NQuBaLcvOHRJKQJMBKMrwgCqcO3oPyWUQYSq9yLixLK7UYkaphk/IygjmSBBJICWL9DVqf6HrpfdkkM7hfNfPyk+v04FnaXLPvfeNdW964apxPtY/323baoJ8gvnibZJMXPQhIjCH1JCMwzemhLEUgpXIBVbYANy/bEv56jCPWfcKzcstU36/AyuDCBlDByPWbNzhhvESbujM4fhcbgZau5RIWPcGaygMrpwgOkSpuUfNwuPMUQ7JlK3B5PB/AUMJcBKymFwq7hBP4riBAsRfhUDGRzbUb1O3RSWx0atXRCYdMFv2z4BrBD6IBpi8IQq1fNRpfd3jXCIA1qmO7TTtaZzb2oWt10c7Tz9lXBO+IejW8mAn1G0P6LYzxGOA6uYGiBlw33KwBb/4S6/kS7VT0ZQgEUclGmEo08cZ10qv05e0T8+90dLZNRp+WwhVM+BspAPYCw88/ClaqTQCILBE46egjqmmTWO+LFHwmCS/rzKDcyClSR9a+FuApVq2Bsfu8n+CEz7fIwCVza/bhs5o9+MFPCR9Tz4hxpqeOlOUpgkwRB/D5fRTBLyAkcYviCPLlS3D7Iq1nNiGmre1NAjsLiC+KOdibook97IcRenOCCytIl+nlky8vudF3fLBjI9TA6tZbLtpnWOiKPVy5dReX8swOnj+yVzZSnv/vE+UUtyrWH2OPygZOOS8gBAlHYjaF0fPEXoboaOUC6nQ3i5O50/ccEXqli1F9eogYVJ29sK6cBf2RMPfPqJH48hXOIkgn8FCsooda23n4kMMsly2Nmzo4PbMqR9L3UDkpohSZEEagVqEIU3ae9WKQJNwoIAmtGWL0dc6giQRkzC1x9OgyXHM6Iy1aIOxKZE89eVRMmitRmEi2bk5xxkRxVxkpfM8Xzm899Fo2QzGuPjZpDpZ27BAJUMFt2UiUyZkTssV4kZ7y/ASLQHpahQzhrTfftPLWbfv40Yl99PGf06f3GMMGC1wuJvr9ym3694JEz/YJWHq+eOglYwk4nuQhOXQKkvdPTu3jzx7g82c5RLJki0iGAkSmOgHOzSM1CJkqiYPRqfqAAftttziNRHxkf2cHl0+9DPPYHZTPgcA6dkN5aRkbJ09vxCe+hwJqRx1DZAcIIIMmdf2lrl1mQSmJZL3JWqVsEzl084xuodgUOpwbDxHo4TMRsOAQT0iqTTFSB7ZxuWDXtu/Yg0fAAPXapjXdlFS2jHntVeHjwzbxj17IlrY3KOwlaomqbxPYy5GzgTxZnyKBF0BSbR2gl6aBOiVuCM0SLGCZvnBdJpVQQIzi3bDRqO0iemRYzRJtQq1sgWjVlZslO9rleBgUOCCzNwOhSqCIeF1KgARFpRZJN18qo/+Sc/v50y/snc3LVPZos9JvPNi4JECX0Ojr3/iGHVNx/Kf/109ouXruRpfsgRkSJY6hV1hZQx2su64VsPRd7Ua/+U+j+S8lduT/KxZQLMPxAEsENARxe/T8ffL0uX8lg/uYdSIIwts6hq3nENWg8m4ZOCtXb7pxe3R+YodEA5dL9E2AeKTrV1CXX333Tfsn//wvSfoQ2cS+cDNZyMRO6WHE3a/s2StL2yArQJgfJiUuoHMD2WQBVVBlHpZNeD6Z5NRRmkZXa/QhQIVtLr9DzeYCWVoOjNCwWhXdEwy6WBhcMZ5Sww0Os+6cnNOHiC5kuL19iHdEsYofpeOcoA7p4k9BAEwisZsiUJBXUbvq7KjsVYIoyzGwOqdQWKIDU0CXpb533MUHTsMhV+BYuk/Tok2JhqXiDbtObDyf2EU371lukTQniNt/zvYRdaDEqTmMGMzfXHAq3ARotcO+fXy0azeLG+6vM6IjEE/VXv/qb9lnR3P71S9+7ptLUT84Jw+u5JKCI2UOgyjtKzzL25Crp4yY5uMLFyIRFXZBDE4aXCsCF4Q9ooZPLW8lwjgylOQ29gDoELUi7teYCaRBD8mxvLZla5e2bPPaFVTh0Op0hzpBbR49f25Hp5/bDTp+rdGlY6kcs2Wiql47fqHvNbfOAGSAteoCfvH0kS2r6wXehaRuGHZUBYPsEjeGIf5sdmZfek8t4DhvkLhNd7ZNiAxpQ3WwG4NsRESv/Sg8LCYb4Xns7H5sYfoqLOO1zIGL8v8ZIL5CY6pKrwlOeU8lYWJN+b+K2w8IF0YwMNYp1rQebQj4UgBGIc7haUc0G1KAqA/XzIdNOINDJTHEFz65rNc2Nful9CUigBl73r9PNkppUY597QhHDvXAUCRbNoe4lDN/iTz+jFM0u4wNaboNoFq47MqW1WzFHnzxPsTEEW+pJg+riWsgKmL9qufz0nP2IH0qo6tFPL+FC5ZDL5dwtwQU17kXRBgsQ0CTJ9O1x58/wChSRA0ph5TIICE2Vtdti1pCfxNJ5Nk4RPm73/gqtQFXKJKhyuaYQA/eTAlPIMVB1h7vHSENfvbJE7u0cBNCxfZQDp6BBT758dLfEQzrlwhg7RBgk2qVbaD0uSSjHmrPh5yCwcb21tdpVTdrANOXbb207XV9I84KTMggKrmlPUvs67sKqrFTq5JxrRAY24KhktRbtJBAUiXqEShinoIvGboxxUc04YgIoE7sDOk6gZrhb7qCoHQqdL1gyXBHgLpnZ7RIQed/9fY1+4tn97RSdCVZtjA6AS9CRDLFPatBFIXckm0vvkzTol/Y7ZcXiaSpgRJ0h34XQMIEjkKqCoEjRMmvbV2xP3vwKRkz1oLdoWBFvVK1zrN9y9D5Sxyt5pW6J8Bc1UWDtmfVlEBKKdrH5hWp29197odIVZySJ5/x9a1Lnp8QdzgNCym8UL2dXCxFwq5du+mGrT7oE2JtkeJ98PAze5eYgogog7raguOX4fyFDRErxAexeIEGv0XW8gIGFJzk8ByWSfr8b//vL22ZAlY9gsgeLxhfBq76A3dhtiVqEI6wIzK8H9gngecibCqxc+flLDWOtObpxCn0XEXiTclHtOze/X3sjoLduXNJhg17Ud5De+LJzz4HB9WweoW8i1rIwiGEqSEkPJhxvgcBclp4LHUhB4Cd6MBAm2PN3v9focKqqnVD9vCIOr47t+h1gxpg8J8+3rcvXd7kqBOxaKpMRhyAcO7ColTjpwhWfY07gXz48XO7e2vLrl1eJBlTwkiZYb3fsfsPsdQ5pSCfnyAX9XIQAVSsngFpfOtyumCN3DkBDLiB94qcbxvvvU8hJkYZ9ofy81PEcAoxmUZinGavWhgk5UkqibjuffprjJ85lv22bV/edhduG25V6NcrZ8CUR+UgAgV+VHcnvX7jLtU+jKubQagxZJ3GUvIGLm1fIta/YsuchZCU0emeLoUmau8O7BzxasN6tLuLdR+jXGydrB0iuLkLkXaRJHJhg+t0vR5CNDIAdblo+9gzI/YRQoWq44iSZtIWmxzAeeml61wdo03uLzEGc3ZIR9b7n+9bVbUTcHUKg1uPQPRjh2HL6Kh5leDbF9xM4tqN37IoaW6psBGd0GZpAnycMIqB15H6OpCc8H5OMk7UIiZBRw/VkHnbd+IBUwIqtnbNQuubNn50z2q0RlPAaBU3Sx0y63UaEeA2KUYwmZSp8jkGKEgQslmq939IA4QDFpJGxQ1oq/7G9dft6++u2M/+4h42BydhKPxMkSiS6ItiE2TIlWegVNUlGuq9pwAAQABJREFUuF5WXSAbXKGJXipBX2HSwzKm6i31DJTdgtexgNV+9Yrdef1la9LW5vBkz776zW/Y6/wtThSXSoZJenjgh7W6mhDUGF3/X+DFCz3rhHijSJMwkUNJY1cbEKpiA0poaUwZcOKGGYkrff/s8MhuvPISMYVltNfcHt37hCLQErH5NXpJ7wMfGVvBQ0at5l2gViELnLYW1ogC0mP4ZAcWouMJRKCuoDdv0FuABlMD/PfpKGOPHiMFOPtYWi3Y7TuXQR4jttiZuJ8RJVUUOFJg7ScPHpLuLtBwY9F63GtBKsbNPaKkyq+cHhOZhMroaYotQqq8Pqrjc1L+hH8+A+mjMG1bF5gAyz12+bqNP/o1CJrZs17FLnO7lyiT8gmTSfTDqUA5RORtjn5Rd235tJdfovUpQxw8qVvtGLcPEXpKS9OtFZUsoQuJ5A2pNUySMZMNIt0ex7hMc2pn0sGFZIYgFqAQskS3YuT6zWfMwwhOBFGMthRZujxNKxY4r3DjP/tP3Q10okRaKOEjJGksVzsXvzW+3hf60WgeKfzoJz/Fy/h/FDmyf/iP/zE9fMvu+vkhTa6Rd6JeAG0ykbIHpN/7SJAMRPF3/qPvkD3s2L/6P//Ynn3xhcPhjZtrGJH48wDixfw+J/DK4DLqUIvczNX8pn26v4c04hw/Zxd0SLdDZ49HJKF24fqhwttE+NboKpJZU1Uz7eepKRxxnUsVxhNdEUzlTOATG9RD9u7VG44XSTtJaJ11FKPrSFgb4zGZIyOJZpC9E64jfqoT6sdwO/pYvjIaBCyFYsf3P7EE+vagdY6YntqriFbdrEBtyDSgWEyb8m4WY4JHULHeGOO3z6HkpaUM9xOgCSSTPXiyx8YwZXm48YX7MsUK1e1dpAZgK8+nz7oA52JsZ8NgGt8I0zEvu9UL9NvSGpFFCErvOZnoWtb98x/+yP6n/+5/sM/vfRrEBYR4uF+SwKWB/xbh8sQo6pD+nWA0/v5/8nftCUmeH/zRn7jdoM99gz6HCAUuAw6q0BHwm7U6hSebJMWohUTM7j7dse9+71v23nvvkEYnc0hHBw9mMYgzizYvbwN7xFUSki+FFNu89hohbJpkULufxPM6ouvor+7tU/dP+v31q2Q3lwjlksFE9FUeU+pFOxgViWh9wpOMy1RenVGa9vb127a0oNPb/OMpWIllwkiwJIyq1jVqHRODkUb8hPmuU1BYhgl5eXXEtk6S7px9a1QOOEFDKxLOyW3nVBvAsSjE9BHNnFSYoUkEIIlLMO7AFafMEO8hEnGqx5PVqSPLBQpABTgHKN8BklyDTsdijxCWlbCWOyOKFX41uBOCv9RE+iyYi0ix/dbvfYPUtMqcMaA0J1fImzne37XXb2xZmZM3/8c/+ade5ycJo+9qPB9aY/KQcSbbokkF0B/98b+y//0P/28P8d7/9DNyIhiaIhRdqOl5SLIoNK2KHe1TRt8Al/Snn+3b/gEpc6TRwgIFLnwvxokjdTBRCFjI1770cJD5a8Q3oljnEgTT9UzZ/67PmxArxTKo0U25yuRCRnRSOz7lvP+DM4iOBJlC27jDPibrECzT3HegxKGPNYJQmE94R5LIwJi5ZCROSG/3OJsZVmkY0nlEGVqfg6LRDEWYSr3Kj9dkEwordIgyiquQIXp0UKvQEiWP0ZEkH0CAQrc1WWjYt79Js8jPSMz0g0IQr+Rlj+K0BqdSCETiCaQ9zy+0aVK3/AQFAYBoVxivI0xSiR6XSAHeYiMOa43jQAt+K1Xr3Min+qoqe9Prd6zynP76HBYRUvSBOpwd7O3Zjz/6wKpk9ho0ajjeP7Rbr73sGT+++tcI1R+Mp/kEcPXVmfOUWhEhKjmkjKZLCa7VddLn/hkEIP2rLGKfE0cnu0ecyWtw8IRkGn/rai+P4yyexLNq9ZQjgfx9Rp+V91kx+xR/clqIuEGnl6P5JcSCeJehuH//lDQ0B04wkBchrM3LqF4MaN2JDH7zRSleoxNdbSqplqiMrlSptiJXIcIWAUiykgzgb5itgASWjYURr2agKjWPJsmihVRcAEKUQFLThRkWfSyUoR993fIYREvZghUggFNclvcPHiPalTuAo7UjPUQwLEjZN8G1rBtCgIwup2EbSIslooQOQa3HH5KpbJITKiFSosKf3Et9VwREDZUjWpfqPQHfj0q5hACguEiHE8qyUF15ACICkFrRdfIwPnv8HL88Q9MKVfBQGwBxaGpZ2N4qTlSkv3mq4cQCYeArt254r1/ZDStUKcm11PH04Cq+p7nFWTxFqApEaWVDYNJhvBAWuBouqMZfdoVWQxDUvy65oykFHXVaYSh/uhTlAxXDdEhI6chXluod7x2Anw+28USWwYVcPeIFi7olHhXYY93NROvQ+CwIId+mBrCIN6SI4vN9qpPAjySf7KUQ4j8Ug2g4CKjCWRmLSvIVI0WL/tZ736PnXBPLvgYF0r9mSsgQV6g/q9tmftE2uVHiOT535Qw9Cdkp3r0NcEccjFCwJuAh1gHgBVFtIkwETDH5PFRfpAdwe0iFMWXP4oZAbDlInIumXDPDGFHIWEkf9QrCzBJsBbbfAEuv1dY2FCIcy+s2p3dUTLnMhqcEr3QEXYhZXluzAnZHluCMbhqZBZmyhPlK8PQX/M0jQAQpW3zkb/3u79if/eVPnKu/9s2vs/6gyEWI11p0sdYgpaB5vDaRtbdxe5WdK1BAouTRECJwNaE5/Ts+kxOhkCXJ5tKDz6UeYso3nOz7iagJHDlDBStlrZthrN9YJkEEPPYrZO2ky5GruIEjVRZTsyj7QsvTAkOhIkSbpU0Ovj+3z3nI4d0ILmGc7Kuij3OaV4VR7SGCSOKGLBHBTGbZoud1KkU4UXqltMYENDdQl5CR8tOHnCo5t8edA6vTfYqdowpKns1SKJhKPxZJsaHGAyBDWqbP51TICEgsTPBW39o0VUXqTplG97hYZ8EC4hyLeoL4CpFhm6CDdX+CgJjgfn3uI8A1EIQqhVREKhdOelvpXtXkC9jqKyDOC6HKpvjRl65ctdfe4mwgdylbL6/a6uaGX6+5nYsdXPznDwFQ+53Ym++8Y1tkBmXhr126BHHhJwfQdfzrP61LDxmCijvofOHp0Z6lR3nLZTb4VI2iCbQoXYt6wO72PTOF/5aU0s5ks4iAZqxJcmSCARkmDR4hXlGgz25P5zM1ITAPM5Yu6kJclU/2OQw78nhNmsijU6bEGtcqbqKGkl10vdrZX13etk8f7HpcYghzzVirwvQvIRVVJBNWlbGenx+Qm+dGBXeub7IzjAUqd+IYE7eucyOju9gGVAo1kRC/+PQjmiPXbH2dQ6KFCLcpyZKM4AQKxp5EjQwPkZbE0uGTM1tdKYN0RefQSaxV/rizoLiC6JqRnpzQy2YKlU6nSB5q2PxuY9QeGkfABCr/p19CXrBNH2NOnXOPah0hXi1m2vLNyb8rkCMX9bt/4/u2++yJbWxfxhgi5CxdCHdpEI3zApUvuEfvyIxc2dxyKeUFsheunmclIWgvzNDaNYI4j7nScGGbCOCYYFIePa3o3NNHT8mhEGLVWUB8/BLurWyswBhjZu2PzyaoOfVHmhGQEllNkWaKA3gZGZIVGrPjR6ekpdUkQynsGCepUpyAImuqaKg2ItjwQrmcZRp0cWsqCL9FYSj+GDGIW69sweWL2HIEY2KKYZBqplpJZxN0d7e4ClOSVPuOzukBKCjw3369S6oUS/MpXb/wrcsEaFbxid+4/TX78OGHcMYJ4pGDI7iH3/p63P7Njzg32CAeT4RJYFSoV73qVdrc4L5ACgjEiSM4X7NopU1jRNem+/sYC8QeQJ7Kptuc29eeUgVEewsPgq+KCPSU/gxUhxYpZxNg1XUTB07W8PkAGyaEwTYHgOJMqZ9br74RiGKAL8Xt3UNEg+xRrzWXDDM9BdA26WHVEcgGkcoQ93ugRZDW8zfvXbxmzUlUoXIBU/ZwcnhsV0kCdfDDVe+AQ8B3usAFFcX3JT2kOiJZbAWwq1PIKEuXaMzkbp0CNdA6U+FhIM2E+A16C7v0Qi1EcAX5w6YUf/CRj6mlRWjl/85b4A2gLWVu2G0qkra4l+GQk8rnNOV+ilt5QleXCl3M1aVExbAbK4zL0ThC7lAQg7p4ZPI06d71l+/CmQPrHnH/Gnr/nGMTjKrUuIcveceMFrH609NVu3z5yC5fimJ5gvgJ1a1ID7lOKZ25R8QrLaky6gFNJ9iPc3IEY3JKhCoEkOboLW3DK3EwesSoGPj+VBcMbdyfoi3AJTISDjNQbofoWSy9TrtawrkQmIpLg6LJQLxKHwe+PxY4xOGFoP5tAAwy1bmkRffOBtm8HiVZ9z/8xN77W3/T1rY2WAecCbICTmdOrpcEkBvrr/lIgNf4khrP7z+gQCPFwZSmLVAPEOZI9oA7nF4pXnTiYh8qAdOXdEpYJd2Ahh35xrQ5J1y3KyAKzaUDuESc/Wia1FSU/oAqt59RecxGA2LQ91jra69AwHhT08EtGOw65yv7du8hKpyq4wGieYCqlcqNrW8EREsSyiUH+4gmVOpVwcDDC9AhDN3BQvXmWe5KOQcL+cUieWh1t6I2HY6rPk5zghfRNCraHn3oahRmAgdHvtgLWHn+P0RAQ9xeIOkQgwpVbKrHFKs5msPV8g5gEqUB16qjiMrSQR9wUmZPBBM8X2S8uhCTOElriVR3ca9QG4hiHA4nYBlfnnCS1SxW4vthopyPiGbWqOjNUgPg5wsBfQwWylMIco2Q6VMMprOdx5Zb0J1QiJmzJo0lMa+niEEA0+ERSSI3Al2PU0dIHcDi6hqVQCfo56aVsL5vrxLU4bY2SsEi8Fkx8wFb/UbTY6TR/FJExphM5XuSASySkPupq8SQUhOuOvhkQs1fB8S2cAszKm8rI/F4X3uUlB0M0nZ8nLbHNfZJZDRBEUie6uY8GT8d+dPNNjp8d0CeI4uq974IgmV5lZsOPsHzwrBKUlkjA65G3d9oidIq9GeLwxJdqnZK3KFSBzkypU1EyGWb7DTtlZWc/cmzZ3CC78IXJGCNyHVnaXDUPOI2J4iqNPGAKMaJdDxXEvhZ5GQKDanYQRAflwFDByyaLepsvnr+qPZAOBTQdNhUXbRUPxdhLh23ylLs2Ny7Z202unLrjkcxcZ8DgiLMGobyxa2SlbuPvrAf/I//vf3BP/ov7M4bryNB2BfRv71PfmlfcDL4/gc/t3f/0X9JpQ3H36gpdORfEICrAV5rsT2lUJEmIhLv8IXLp/R0FvdLPYUWKdP6h98u2UcYXxjd2FPwOOooii0UJxETxRgLiEL3FFAGllgcVVFgWYCQGHDPQEDSlDWKQ9zT4mPZWbrFbJkW8JJoUhOCjphPx+0XossYegm7+bXbMBi1fgCjwe3q2yCcQSyCFC8SnNqvnHEY54UBCQFUT6mqZAFqFqmTKlG5DIj/+YLuikWuH4os0Z1yRLdqaa00BRgzIWQKpUGJZVRE/eJULetx6/aUiWv4/16Ni2U7hQoGnNYRMAc6KPJszwqqBEYK+E0NQWiH7NsZZeN9jlDLYIt4Hh3CYEwBawFqFjfQt4tVcA3iYSsLJdf2ORf/FmKvRTSNHoPsQ3aIJApY8Wqfr3z/P7RT0rv/7L/9b+xf4naR5rAbGGcvY1itAcHS7/+B3f6bf5uSLxIl4vqAMp2YXEKBGM1dI/SbLxUpk6NkBZ0m20P3M1SiSD0BvnyDk9T0Mfz4wSGpWIxTrPlEGVcOhpjTy1fsOiDaGrUn3ECzbSkSWnN+a5POCOBUU7sqY74cOZQsNpja5kM5wEFGusrauIg9OgnAEAqUrZNY2ifAE+WGmWG6gGvvKc4mRGgs3ebWfm3a6/SRUDI2Y2VhUutR/AaXQKdLB6JmOEaqKoJBV3iDNC43I7p2eMZtYtL2EckbFRXooIHsKpQUvj33+SPk6gATvHlblLq8TbUt4m+O2NFD1CxXSy5Wj4xjgpi07AWqEmgZQ+NCXj6tUtU75jYqbFTn+yX2FUbWb50gVn2AAOMiUXNpnUTABtVj4uRJ+2Ln2NZeJVfPncfCBDnkLpID0R65LmJ/9z//r+zXb7xjH//Zv7FNWry+t7lqcUR379W3bOXtb7AUbBKJX+bw/bCGF+JfgSSFeuMQDPEa9z6Gsj1UKsb6J2RWblIi9503i/aDDx5h2ZN3pw9RvLyGtd3jvkMUfnL+z1XZQBVN3O+3d2ZEKrw6SMgQ4rW3F0ykcu0iATXFGJq4WplNeigzzgw3UGcfhT4Z1hJyNsY9p8ZSrXQnGOc6gd06H9qzj6gCpteS2u+KYAV/PYLyeoiPP6OdZ4cuanWHKXGVijancP6cW5+odXsVJJcQv7LimJqgEVwiAwuENCnRLhCzBmQQN9pZMpvX+iyBWySCUdMJ9eJrwfkKfGgJgdhnA2wOyuN0zMSe0840R0y8Tyoztr4EsHV0S10+cAkhyBnSASXmABKg9KM5w30KMTi/mAfJFSVniFWMUS96eHNJ7AV35SDcN3/3e/al3/sex90IZ/P1CKXdMREKRplDQ0gXREUEGoBrJBlPqBdUMUosV8T4RUqBkB7PPsWhI4h4ORu277+e4whdzf7qg6e2SEJtRsc1lZbNSaNH8RaSZFITcPKUW7wtdsq2j96tDA7t9aWrPpGQ4U/ByJeAN9TEEtb7rGnA4Repnh7SR/57WjF/1olwIpvLOUCQPFfwikRPl7U2wJ/SvnHUQZjgUpKDojqK3tmra1vsH+nKT9TLnhG5A5AgxMgriAHg3Z8dI8KVRAnTj44IFyIsVUJC0BpOwkenb2t0ElshPellWozqyRMREZ27ZtQKCng5XEJ1u5CP67qbPHiLkqUyPq28BFm+TziBDKrtcnLd7qPfhgBO4dAxhNjEUlfJ+AZxhzi3dmVPkCHoF7fwO01Ti4fv/4W99/f/gf35+5+YXb5sRdwcNXJy4LluRazye4qECbKBIEi7gIt9FMbS5fpPPrXK3nUbnBauIRshUJbnLCJG1hEVSg0aPZI57EBkE8rYt4iJfP9LRQIwNfvnf/QrGjDLMWVlIjxmEdLGHMxoazwWL7fzFodG4tw08+eVZ/bpfN+upKjZD6ZHzHOnspYaR9DihdvS6rfWNqZTmRAmI7GINKFKnWyqvBNqMek2osqfKUWhHTK1fYgggme0jjuIfcgBlh6FJ0ANfPZhBDnXANADaVFRlxask6deAMlkCQV2EKvZknrV4cLhEQyrdU9HditQEHpoSgCjQbTsOodFJZA0hvS1iOiU0xqKb0s3CfAJDl1McF8kZJKohh7zD8hMhQhTduC+BsGgty9d5bQQpcoVnVAKfqQOWC8iXTdkgpoJ9yoU7AkanxHqR0rU9x9YZ/eJ3aD658njZ7aHtFJxiY6wKXPmR9VwFZTNC6QUCxWyWZsQJCYYIo3UDURPVQTLi9Bp3ymAOzwXt4vrOyRd6B+A2I9Rtv36Ztrevpqy5zt79sGHD61ArZWKSgEHiw5ca9XpJQAM8GZKdekIKncWUhR30H7nU4pQUhy/i4bQ8zzU8uac2n03pkG27BkduU8RAxBU4gWMSnxlHRL1M4J8R3cpf0p0dgZTTbm+hCudpFfBGLurgx4c0TK+iVeS4oSXTnxJxckDOTykAEbVOcDYs4HqioXHwMC4ZABFXDRo0jGUe9yFeS0Mdk6b+OmIZQZS61hvLMFrbRBZBWWR2+Ou4ip+FIBliQ/Q/37Yk9eafJEbK1cQ6WVEonIEun1JEkDpWrVI7SGixeG6BVuaEK8Q2GOMrBeDaCLBWEQXcO4mdxP9+E//0K5///f9iNiELOMMoHURkV1StkpNa23iPqk4rxrmLYWZZVsoGyjxCdjoFnJObp1qGgo/Ou2aF7+OIFCVjos4sIJslfr9S8yZnNTshz/eI6bRtS3+7qBvtS5FHkUDvj5ktA6tCB4hIQ6CR6iw/65dWVilI8qEU71VP4wDGJ1Ii0icc1r0bFxZIvaPuhIxyTgFvPLSpsBojqTRHdh0S9kckccO5/8iGHdTVO0Ab+4JPQObdAHxbivAV65tm3kKXKvFifF7jBMtf+m29TBwBtxISC1XkLMABfGICO4ijmOIm8bjQzfuQtwNJAyg5goeAVyVRauDV0rvCbCMLPckwV0s8cQ4o98mc5hzLgs6Ygh5UDG+uTZz3ifxRE2cpM9hv2pXcxt8rku0MX5zkcaU2J4iGqfEFQJCAxAgT0TIBR7kKZEA+eBf/K9Wfvd38PFz9NBDfPNhhO/EMN7UjTTIVgJM5haCFI2RlT8HQCJ49Ss6ppq2Tbg0RppWtfbqViJVphIwEufUJ44shTV9dl7hfoqUaSl1i6UuHey2CesRJCQBglfYQ8TfpXGVxpYjDJmTc8GrIVmlhNs+refbNIdQfEN5DhmtCoD1kKT6LU9JD40/pdhmhkqdUSqnYl0d/kwi2rtiWK6RFye3uXSFk0+XF6yGK17Z1QEe4MW46k8kJhQ/q1IoWv3kC/QEy+IN+driEImWCa4gNQPAKGj64BRDjj2Oa6KkxQD92ENHDzEUM9QRIG0CqOJ+dZ7XPFiRkUiCcoU0cdkL40+z5xJZDnvUEG8Ru7u8hRpo2Vmck7bq5qndOooCqz/gJFw6PhHS/TQSn6ui1wHE1aqyIRxjnZ/9oY3Kl0l3rVL1hp2hvn0ATPNrX1IB4igN70YUHPoi6COEz0H27sEDCi4IkJFzULHoSgrEhzgOD8JxerxgNg7HA25gBkIhKN8j/8n/liQLbvsiuAIfTkOrDM7D1Dq8QbpdYV80Ah6RaiETtJzjEAe9AkogW4d0VP+oO7O6uBZyHB78z0vVZmQ4DSyZpdiAHl24OUIMYxGXPQo+T7ip9PM9jFtwKMTre2ImSTxJZgDhbmV0GS5Xo+EQnZiVdRMXFInRr0CJ4cNzv71bhE5hamykzYVUTIYrp0MM861FPzadgwOa+Lp+IyUmb7PwPkaJgC1pIu5TBa4WITtB0JLxIiNGHUjXc0TgcGMe1/c57YNzNNLWAoNM0bckbqgearGmXjiyyCUiVG0MHHkpooXb4aA1/G+bHnADx336B1KTABFw51/OPZKUkV8IwNscXxPAVBmsVLLsAN0DUKFeqUT1M1SjCBVfbqnleobjVasJDmVAnFJV2oevCGJiLwhj35tLJOl7AC4vRshTyLm7dwAXo151HfPMLgWus6SoHl6Uq8DWoGthHT2jDW8aT2KR1rlBMEsswA/jCX5iJG+Bw99ZPCzdkDOLmhzV2vaEu5Dqc8UKogBHUwAe1sL3YcY0eRwRpeCVIw4TLQtgEh6II4UnVQE7omighBEoToGxuNjjEBABv0F8AOxA/PTQjdJ54ibAwSYvEONUK1zDuYzpyOIaXae26F2qZXRqdpOz/JISCxzxOu+QXo5wjyLKoH1uiFNBli46WD4/KQa8CjYsYAoDjC0JIGNTwNZD+l0MnidAorqVOLdej8G5ckdrWNf39wec9CVhxI2wTriFm+hR7qgkhO6XlCJaluXgixAlCSA193EV446SuBZFrwWifSvUO2g6ARpGQq8TioWgnBiZf/XGTTwdmA8mStMIIsaCZL2remeGfTPhfoYx7B+pWY2TYG9jjMoy1cQtMmFFjqErfBvBaGOzNqUGbiSJjP5Oc2e13gnhMKS1pDIUjeqe2ps3OPzJ76BgVUY9isafGLrMI6YHOsCGmg5S1jqXeJkoarSJlQ8MATqAlJUMpeiunbkcFaoATSXbOnIsMa+mSCIAxd7zZArDHCELcaDkt99Zs5/foysl7odPA2IYTvM5YrQojS9CGXA4oYrxNOWc2gK6Wl6BVI966Msy3uV4NdanI1bQESEMQUISQp3hdnXgWjWVVoBF+W9PabIm+cPESRz5kjrqsC1ilNRScGrvfGCPTqP0EqTU6qRO0iboPZDJ0A2kXMJtgtOl83EB5d9Lvam+QLwR4rzCvac1e+/uMlG1JjfFatlGmQaXWOaSjMp7qIGTbgylmzPqnokjimBkZHr7PfYhCaC+h3P2QIyVGgvpcKkhbCgCYzg4EMaEKh0qmeDqMK3oWngeYYJg+q4ekjYYXeCIJ3vt08PoDodElV3UuQw0M7fegfsJpbu0YGwxhkS+CEJhZVUE+f2WuAmlCDi6Qmm2EiMpqGuM2JM+5Bv8nSA61iUMnEbMcHNHNq7AiW76LINGbdnWMpQbcXgjAef8ve/esL/8dYVzg/9eMSWr1AIUW9BuRYS6mdIUC7FMzcHVhRU/Qq22NELUAm7g165f5bw/CSmIRvO4kcYYQqIKTMIUnIZpFSv1IhEKei+Ao3kkrYhjsMY4HkOTqNk+zaUf7LVQI8t2TsZsl9q5LLeTI7JM6FfNpkAwdyJNUsHLZO7qCetiCt2PaUpOIUJQp8Nt7X52/9y2ECvrcGiGAZoQy//X1Jn+Rnpd6f3UvpHFYhXX4r432Yu6pVZLLcmyZMuWZdmesSeDGQSYIAMkSJAECQLkD+h8y+cE+RAgQIIkM0EGA8zi8cwYnrGt0WarF3W3eiGbbO4s7mSxWPua33NLSoZyu9lk1Vvve++5Z3nOc87J09ya8+0QVDnDOCbOjLh7QQBKHDDlWYSn+Eh2XRrw0dqGSmzCYh6xvelo29FW0vZIxcdUuMl/RUzoaeYErSQ+Q/sAefAjCnyewuMAjSZuvgjyibO6rY4+rJ2GbpfRGBLGGuGkZi9FWLexoR4whTyHhSZW4BE++IX6m483/zoYuvDhUajEQW5YN9vJadMgaAneCTfVoJNVAgz9AJWcpgeebqABuLH+kDp4xsp66UGToUHDlQsQPVjEgwO8da7FP6QC2Dz+ZrNUpOhHTMO0OR2ir4/zahEMbZgqg5QyDVLvHvadgzHweiRXjouuI+0RxQYkOLH6WYuysrzUIh/DmmM3qW4CtXR9Bnhwnhd/gr55OQAouG9wNm2LjODM1av4ORUKOpfRZqoDhOBCCjc1Povmo9L2/ClgT7syKNpNBxIaT60+fMwGC4tv2SpdyLcOynTdaNgEQ5uHyLzxG8bE5W3zmPy/cPgEpAt8ohDgmUyUCzN5DPQBz4bm0QYKI0H6SlW0F68NAzQpr8+T8R45avggvDfFvmid9CXegk5xALzh/TdnmAhCvh9TNjI5yC8ZzMHADbXE19TwMoUkuY2MjXGwjyjpKyNwmq2kCqdnAFpNaPWK2PyhSfrLs7B7SxkbwBtVl88imbv+AUquABhUbjQAvz8MHBtFCxw+2bS+F6esTvKjjOq8uwFruOMUoaB23pO3C7MzTOjot08e7TrJ5TjQlYLHEuULzpqHhkgidJwT/5Y4zVkAij2QKsf8RX2p8/cxWLnjzbMYOjnacC2CS5kiMBoIgVw5pzWPx1zyp6wP/tw05Im796jS3cqwcL2o/SJJImwgTuAJiyPVGgzDVAaa9fkvQrHGbJFxy33x1IJLa07Q+DDG2qYpgE1AeQvb4p07jszswk42pkk5HMCorVLosgFoFSVsniSjOoxJ8OJMp4Z6bXYamhs3eMwmZOlzdAYJQ21wa2zYvWc0xlCMzAdJA/A/t04CzJQ08tO9Q+Us8hcYKWwegDYJRBMNFIGuJxj+O2/Oc0BgY7E/AzPUaoD/q7E1+soqRFOFk4oVNw5sjLa0ym7GqVuoNwrgKmF78HzXGD3GFPMBtDeHp4rkJob7LX19zLY+XbFJqN+jI4MWPMnQRYMZdSE1jISCBV28mwsG6XO299PPHHDTh3QFSRMnMA11QBLZLV/nsL16Zd46r9K0EElT313E1oVTcWxmHUzah+3y4GRxjHEy8WRJz4H5ODUaZ+xqaYvBizU1ckIZsvGuqzgQsrSAytOU7ZJ+2WNI4qvfestevz7HcS9TGJqxP/vJI5JKnfb4yanj9veL8YS5AsN2BFGVgMvMxVMpS3Dy5YAqxteGOGAIAEnaSwK3TplXhQWMKo7nZyGER2CQyq8lmA7oAsF7RneuirfHvvvOq9Y/SIMJJqalyMr1p0SM8dv9xX374x/fBw3N2txkt4W5trqiK7unRlgqkys5R1kQbvu0a9NbsttoaGEvDTZe7XV/+BswnbjfVXySAMK/+9kXfDbZVplbIHSN4CltH9oVGD+aSHKOlKnlXZw08v2lHTiYfdYzP4gJ4VhxuEEgSepQxTIEaBDn1G/t5xzpcBgPvUET5XAa28giyRGLRKBL06xgCGhYTY83oB+rwFMhoGJtPz3pO+KjVicO749Tuz8+hjOE2uEGhdmwaq5tiTZf83RVZubFgQGP5mGJFPC68UTs4nyBeXuEkZgTZbFqSLdi/iDE0wB/fLyfw21vf+vbNEvotD+/x6xgum9+8uEzVL5yDDhIkC46QdoqNEIIRmiFgvMoTD9BrYPYO0gVGy5Hlo3lZKjHgAAY3adavjy9dxdTkLcUHdKkwiUggmDlMyTgDUi1ayPOjo9A7Absje+9a0UygKuo4HIWWw0W0km38vEE9ZRohx++d83+8sc/x89R1zPCQCIS4QJxYnpPsGgZiLhtMA2nknvQvbDsliNSUX6iH0r4D759DScVrUI+YoRJoWytOxwoTQdZlwgjcySL+scIz3kmsaCOIZF0EcY/XKajSoLOZJOjIJMgiZjLGj6Sv7X90FpDV+yQdqpFELAQ+PniZsa845QX96WxJzgPPKw8xn3KuRRaiFd2ZR7SKA+z8vyYDlodUI3IdOF5FxCI4i7t4IgkgvxeYdkm7yvxc3Xo9KkAldOk+TladBEsmF2H5sA8dPfgJ/htfILkD5KrhdAfcQaFOAb4WxO8fOAAF3F+enqT9pTPCkzPWSeC1ny8aSFiaLI1rlxb7wkiqM7/4IRMzy+g/YZYJOBVNlNNoFTb50asfGl+0De2B19hk1K27r4hpy1UcCJgJ0Sb+XMIsho84aeOsYXT7KfN7Wvvv2tjVygQTWkYFQcKc3O8T9f1x0/tJ7czqGUGXMOOjsfTzCvaoHEEbCHe30uC64BsZhbTqinuKsDRME1pRqFE8nlSPWQscR5/8O0F2EBw+mg9LydVYaXUfpMQV51YhjE9RTSt3reyfkgjLIZ9MCtAHd7UdEvp7mAAH2FrgyphGNngIUIS/THPulU2Cc3Kg/Tuxz5DlOihEDFMbKqE+tYujRbY3D5CLzUWFHIXQ0juPdy2a5cHKZ8OUXEKSkZIVi2eW357i8FII3DYcF7YACQH9YMZoZFzEwKEWsf6Ee21LxYdMCHwRJNIpFFEU8IIOqGo48Wf83qFVWoCoTZvGiKtymENqIiTqMrTAj3W22NdyloSY/zu733LfvaXd237iyVawY/bLjSvfP4U7TaLIFEFjQM5MDFjA5wu5JLPwd5jcmTH1TqmySZkNvfs0w//iEQYE7U4+XJDS2x6nsGZAodc7gMNImHYXc/Ywssv2uT8LE0iumwBH4Ttsy0qmW9/8pk9fEBX8o01yx1TqIEan7t01Qbo7tFPOCg1dcIhWc7u0rI+Qo+kLiw4njv/L6/fmTuEPQ6z6gffvspoGjh+tNEPsnlaC8WBLR5Cs53UuzBLwi2LKTlU6Rg/P+HkJziYLtRDuOcp1Vf39TJatokZU6m7/A7/+gaS13cI8JOnLfwE6n8Qe4z3jJevuHSIAswtVH0dRA7h5t9ySgwyKJ03OC1SqdnyLqYhQSQBioffEPKC+gWhmoFMnWWO8HgZXFBfMw9z7pr5PnLjfZboozXMc37GReXsCHf2gtxJ1aoeUQufg8WiDKIqkJVdDCB4nUnCJbRQjUZJAUAkva7OA/dS0r4wnrQr16dsf3MfTQVlPN7tQB0VryoRJNVPKVQ7P4A/EibR1AGfQf39pOTPgU//7A/+GE1GISavDxIhHGQ4IOACMZzCUKQHDamyc5wyhFhp8JlL8+7aOdTqMiVxHdj8D37xa1raPLDtpcfUPNAEmrXToMeTLAkwVLePU4t+w5FTlxW4iVRmy59wtYKsBd9xOhXp1Ow7by3QozhOtxI+L7SDQOODoalJk+E/wbmEfiz/gf1EoNgj3hcjrJT2kAnYI40sdJcf83oJuhJ56j1MVMX3/onRm3Z8TK/9wgGx4ed82BL8c+xea8T2AU8GcCYmxweRLNQUIZx67iW6BBYp5KGO3qdwooo3DY+QOsEzGk0Es2EbXLjoWrtJHca7j2wKzzMCOWFnDXryPqwhBEn6XXCrvNE6WbIA8bYklueXiaZLCZ4tpkCOoBobaqydOoznGWEjwEo9fGqUUgXJjD1a22dhAVVA3nqGe+wRjac6evrdQ0tQVVIlfl6LTVWHjpB4ejhcudMD8gb0OyJC+cn/+iNXyhbD6RNqoVtJ4gPkAocQP0fYdEHGym1wkvCPYswjDCFUBRxFFV9kSYgUcqf2s59+bHsrS9bpoSaP+geVm+1ldkE0ocUxiieK6snjxSte72WOjxcql9K7em4hpfqSjyHE9T6atuUlQwncflpZZZoIvgWCPQRZtxd/S0myR0RwZ3kBQTR/Q6DZV7eAy6usCQiT0D9plTycRtQsPQXVnR2BQN8QelM+Fe5GAuN4zd30m92xwOS2pXvpmRu66k5HhAVLoEIC7JQw8woh3CFNIlboiyuVd0JqU92ow4wpCQHpBnz71j02Tm4aHiEJoR14hxMTF2mgPII9vEv+/Jl1Fq+Tn6bJBD19HWZAJ+4yJsRNV+A0yPFCsyMQ4vkTN7NZHlC0Oh79fg4C5ukTG2hmCIVmmGU86RIkm0j7KC3txWJGV3LaiRw4HXJQ49jnGKe+p3ACuQWWM6f+05//nImpRQZhhu0hmurhh59aCDvN0UFkpIZFaO2GXcNMAARUvooEUxqliJ+RGEg625pjureHOYthtOGdT+/Y7vMV/JATS87M2evvvOdMW1dhxf7ib29bkI6sPtG3WEcMEZoNGBi7r3UEh7QCueuYp5/PUfhLYo343XEBGYBVbiJsVdrORQcM/ontdRw6TubM1DDC7LfnGyLawuHEPJ6iPTsJ/3KCkUm559Ac6j1UpjexWFaV6haFPtsIAW9IYEM0eSIUnLCjw1FbXfkUx4+yol66gAECHZDIEa24go2pAYbUgHPVXVwIopw5haCic4XYJI2HLWaPbW15xZLTsywoTQ2yk7ZClUsB+9SFKo0Gab4AxTzJCa0yPkZJIc/4iGMA+fDWWxQ4lHc2WSiuhwesXHqS0WtDnXU7AEbunbhk0ccPabMK9635nF7+BSsk5y2HqsviBSfp1JVMD1KZQ7MLZdsgrg7Ar4v681DO8vbY22v9nEaVgM9euUbXk4hNXiiCrM3Yf/4vf8ZwSaGemCrMxXFmnfsCp0AoZJ6kTbRZShj5Q2QcOf2cCoSD14LSbTx7bpWTfRsYHbV//u/+NRW7/fZ3Hz+wh4slCjbopHK66QTLdftExWjcjnoCam5znvsTrBv+UguiaOg8RiSmveF1kQCH0NfNhpLMAs/fpeW7kjzKcsovUBn/1BhmWNdlX4b7yWimCKtBBUvlXvo67FqutGb7MIPV8r5Ip1d/BwMUVT+eYySKbIZjyMAxf46Dc3ywyOP28qFKIyrJImwd3lnzxH1owNtu0BAG1lXN2hE9BQqEFw2ksUdpVLpsebiRJq3gDg/XbHGD0IoWL5PD8ywaKh+bWshs0kouZ5XMNp+PnUYgvYPD1qDZc5VByNIOAVTpQhdhHGBUmHzBMXmK4/hFO6J3XjeNHMPHu9DTOKEQNbKgcYMjKRufn7J7v7xtU5MkcbCNi3sIC/7D/Yvz1kfjpVSsbOuowCfPt+whGk3j10cUw9MK/ve+OW73H6zazz+6qxNC27d5nFGeQ/GlU9PAuPgYwvElAJo3HKad2w6Npk+2Nxys/vX33rf52THrwOH8w80NYGiPzRGWJoBws/kDwlNwCXAT+QdVTCh8Izeoq075lrAJbazyDHkiCJW6qwmkDqnsu4pkg3JcgamV6NGenaEVNTzqGc0jxOsQjiBwzSXv8J8iHeRY/MsICVRxiDy1Ao0xuy+Yf3tn19mdQpFqEU6Ll/ZuGuuimfTBJNJdozwZVcxzOPUnO1vDFmqcq+ydiijV804ZLpEOVJYFfmgBSBP53W1LjMxYGSFo0II+vQADmQHGUf+OlVePcWhEllSuHLCDBcEBJgwEK0AooqMT9K/BYeLgaaGPaCL5y31679DQKSDyRPcIvQA+wuPdocm0CkzhFnQNMS0MO4cZHZsbR3iwsztrdioEEKx/NLts3wZfiHGvZRzSwM2X7b/9x/+NKeol55EC+Hliq4sbNvfKq/a935qlS9mebWYxI9p8QjPZfqWxZQ7cLF/Mi4ZMN7i+nNWDzU2rEzGMgIYOT4zS1OHYro4N2o0XZuxPAGe2TmnrTo+f3cVT+jISymGdikwNqwcRQJo4idu3A7DvqHkcOD1IB6G0hlXKa69QTr9F7Z+GTCgx14ETG8V/kRkJYKaRIblV7j6lZlQ9XUBb1BpkXotLAEqMjwUaL+SYNNo5wTgA4Hdl4VQtE6ATl1RJU5Rqzr1athupWfJaCAX/z8NKGs/JwuVRie0xZ+3yKzUoCHKTclrULSsAyyeArTve2LLU8DisHzzTWJpyroL1LyiUg/DoOyE1euIgW2kepT0V/mpItJC7MHFudGpWu89nUTBReGIzUKROEdAmWccuElQ9npxdS+ZtrpIxD+3TNhFWCaeSIgJ9agjxETGyB4w/nVlCixSJpaftXv9rFLiG7fEnTPyEUXsG7+F4g87fkDIu3bxpT4G72Sp7/d3v2Mp//UP8oZjzsD20uFW4WZf3zvZIC6j4tSE/I4D5oSuXSrG7+gZcxm0P/uQ+oNnXX7lsv7rzhJmFOVvZq9vY8KhtLj0kvCU6YH0jdBPtInQ8AF4ukvDqhNCpNLqEjP93jrEcYTF/XGqXBlJiAGXpGtLC7xFOpeYe6pCijK4ALpf/1zW4X84l4BECHzl3PlW9hsDid+xzP77JCy/ckmXTjHp1qxBwU6Oos+XJonKoJsX5kK3JIM2nABHC4dW3XnGzypBlG12a8ssTonCN5B5xvTxdFSGkcMjwTjE1iTDNo+gL3Kp0kxYl+UQUESTV2kCya9g0hUYK18SQEdkziAfOcbMgk6+CuSOboUnS8qn65+LBC+CgSXKGLtsVYusGCNyJh8QMhE7UESychEszr925bwsR4vD+pP108IZtzd10mcLM3V/bZ3/zKdCrUEE6aBdOne8SoNglBrV8c2mRgZF0/z7EoQU2/p0fvWWLT0kU4ZQ1EYAyEUiQiEh9hcWfUCb1cGOFTWK4NtorxIYoZBTQNQ4jSDDzo6V1vHUacaYCtnO8TUMuoHLyAmqQlSUELUKz59YBmSg3Z70UdaikTkmg9snWowEX43CL3CKNpP1ii+ECINwFBlZQ7n/GPmlWs7SWiyb4bC9NPM+YstXA9NarzE+mN7PeiwBcuSWPs04ZuCNkYtNEeQ7HUEUE6Ef7QozaYYRAEG24evDLxkjNyCtWAYeIm+rC3cPGoPF5GFQjNxAgKhgEEj6lwKRJHqGBim7STbQjABcALx3FoQAceBfnh03XAgoc8iH9HgTBf7BDa3ri+q4xOpUQwpCTKBI3az5ejsTSk1zQnnvG7CQ65uYPCUA5hUsXw56Hca6u+Y9t/4TeOtFpK+1nbC5UJySl1n7zkSOOjl2j/87zHdrMHQMeTTsSbBXC6ksvztu3vvU1e/2b79inH/2aewnbv/r99+3jj3/NZinVyGbxJ57qcZsitu/J1hpj5hLMWSQ7x9qofFzMKU30HKEiehvC6e7OPvdegE8AkIUGjDBICgWH6AukAZ5lgwP+LpBDzCMLGRFrh3Wo4AyLdCLzkABYK2B2FTLq35wcB2G3vPt8Cw2vTvoaLaiu52dZ+IZo7TpcBS9dXyikQLOAItDmXvrBl+ofv3UGQHHOQ8mh0QsV/gQjnMoajJQGZAlUjNSRNl2OiKRRnrkKN9QLR8OduukE0puKwUlj97Hn8uA1Zs1LsmR4asxVAMvoqXeNa40q7oEiADZfkqymRgIo2tg81+X9tR7NtydHjypPT0wDpFBnQFbxsMap4wFboHhqHBmF9CkEVTF6BUcoTvOjQSZ3hCnjWsrQ7ArfIIt3frmbzBza5s4ni3ZKQ2vxHp989oQNDBDvTzoGcSGXsX/xb/+pffe9rzvbf86mqBHF3/7kr+0b775j3/valP3Vz27jqCGIaIFuJorIaZdXf7q7AV4wQIeSNOvI9HUwB62Xeg31EOYm+vssJvIJpuTtl8bhEnDo6Moidk+OqEgpZ6XG1ezBT+9Gl3Bi4xV2KhKIUb4uLdlHXkBzghQqYzXdl/ICjZYYTnLUGQ4hH4P3suMICn4VkVyRxJMvAGaBxmrWyUHwbl8s0X9LYYMPNaiTLXvsgV0SjMAkZahQjYbD7YZDCIVOKKGRkhiaLShOWZKNT3HqO3EoZKP8sISacujwYsUmUtv1IB69B/Bir8AHgm6NM9O2S/NsIEY0e4fNT7t1XxcRBVB0SCoc1cdBxi2gpy1VMDFy3lRS0LqW4UhM3NqjlZymbApBzG6IWEqfQxhKVTbLy0KpAqeKpon1U4TKz4YOd4gU9qlU9tqdu6s0SfDazMvz9oN//Jt28eXrTDKBJkVqOEaFzxCZ0S8+/4LT7be7dxft9kef2crTJ3a8u0W37Zj91vtvWi999v7mg/tt2889xHsHnOd+SiQT4OSmLwCCcahkzjrUDIPXCLETzb0gXwr4tkg+5TL0ruUn6wyOYi3RiHohnhSAlyaJnrscvmY160sapkGSSg5xigwsYYmLCMTWaniIEkwNu3W6laUk+YUA6CA7JjT34bKqqHwPlSKaSVhhX9VP2TcwOntLNyrGjU62Fr7RwkuNwXTxdCEpdJMknHDhmE6v/mBTBkCievBalTvgn0iTYlp+RyMi3aykVmnXMqd8F7s08vINUpHD2D0e8ihj8SA5AoiQUW4ILcni40AS2ogPEOBnaVR3Eu84QSl5NzZ5l7w+qSoD+MQLJvJgETFcLgKJcQ8RNkXh0PEeeXNOmxc8oUszCXmgIKp3Dc7B56vb4AAMZiB/3j07DccPZg5c/jCCt00F8Qtvv27TCxft/t99wIDMp/AFsjYOyPL+D7+DNoMW9vlDfn/J3iZ1/vDRM8rjGS55eggg1GdRQt0SpnNvfdUmX3qF1C8aCi0n5FRcQ3X+Sskx5flEy8rsndks3VZypHuTwRZZQVrpYRI7EHh1bo9wIMEtXTNrzR/URoodpFOtTqw1pF/75cHhPi+vA8JpwAS+jDs8bU6lhEXv0wsbgDW83DmUTTq6BsETxLP09aanbsluflUVy0u52RNOr0I/MGrMgGJ/OTFxQg4NSgyh0kRw8MNMcT6AfAPCQzU1UPm2OlErPKLtJ6VSABwgWPG5F2Fw4MAJx+eBQixGIarsHylWbszPIAMPNlUFEFWqRb0qm0aLuCmmdLBW46pjppkWefBJz65dT+4AGkLw9BEZHJxiC0kfi0vIAvjItIkKrRY1QusKQNpbauNG6riEPYyC2O2vE7IBNTcBuHaeLdtRhpYqr7yM6kYbEM9rYsg/+Tf/zN78xg3uAYiaoQyLDx5Z//ikw0SuzKTsxz/9FacMJ3ljgyfAr8HfqQEuxbD9fek000dP2QBNDEV1swmq61ekJaKpVL7qCy+MotkODixNU0h1Iu1SdlKxPvevCKgT4e5AQFzBKYCOhk+JpoccufXRtBcpeqn+rk4KWigQ6YgCgbNnaqEj8+To6GyoTEoojGCKlocjKNPrd4MGUKHKdUtaGjQqJKbh4u3eOlIlghQ5z1xYiROkTBEDIQZAPq9HymHxquRIIZCkkrt1HbNTqD3V4i+tHgCz3rP5r90gVDq2EVS6t3CISSH+xdvF7SBjJr+Bz+VewvxpQSxZPSK3PTBli1CwJnw7nGYaJODx3y5A1ToyO1Bjix7SodxrEJxAjRtVANnws8lp6vXwa4Jp1Cz3tkdXs7OtA4f5K9ES7+i2o8V1228tYy5ol4s5qxMCV0ABT6CE1Ql1nz54SMwM1XoRs3FwiDaj1Ioo4MHqqb1xYcheeWUBYepjk8yWnj6ztQ06lZMzWb5/n7lKcy6LqYOQJXaPcv0DMqIp/BIN2YxTM7mTObMrIHclPPQthE7X5lbp8Q8vkIyhCm+Vfo+xiwk0ZJ6WM7sVWsQZ3Vi0R1psNGoXfYAOSbZVqMfs607zXiIsTLUCeoFCaoCtZJOjpmE61JxKKW5pe9/QxMItF/7xYl2w0aIaBfUttm0UIkWM0E1Ag+JS4c3is8ltFTYvU5DN70ByFPUZU4Cp4LqEI8dQvElzciLL9Q7L1JK2u7llfhZw6949TEfYEUNOqIg5RT1rFGsDL7sLyncH72lw0xrSnGOegI97yDL2JAzvLoxpyNJxRADHNmVPm3tAtNxnBLvfwy4MDPbg/PUQaoJd4FCeAM2GmP0nlbn6dMNKNIWQSk6RoDk7OcKxwlYqyURb+RLOY5GpGkgvnn23jU2M2Ed/9dfOXm+tb9hjhEH1CcOTU+hIThIPGsHfqWK733zjhv3+P3rPvv/uK/add15zo2NLAFPH1FNmESDtU4KNF8lDNRKy9TKTmhiOygCKJqOJzZ9IY7JY6zIaQkQO0b1l1rSJBcK87jFIqIBQVCggrKpP4MKIg8Ap4B3KzE7o65hGK6FB+SyFjJ3SOjLh7mRiAiDc+PxAyPRZVPWyrwcTwFWcqmqrepUmMX4EMCJKpiqg4UUOUJAA8ACoeQmACJyCGfPlI0AZmkhjBsT+Rcu4EK5fMTIMobX6EKHGmL33ox9gEU5tHCfr5ttvM8dmwgbI02/QSGK1ANz64jWL9CTN39trfjYggE08ZzTsA0CZI8qwPGQCJxMQThm6WESNuzatUJ2Upk6N9TFDCOavbo3TcgoAI2+5xSYpFk4MJZnosUMbfPj0JJ/OOeEinAq/F1CCLgO+ZdLm1rYtP7hrs6++TJvWCwBEe/b4/uduI/qHhmx4dBSQKc+UElQtm8SH2y8+uEuZVspqdAAvM4vHB9lyambcriyM21X6+Y9hgmoAW7u7+6h+WrwpvMOBVtSg6p+9w3O7OBKhz1IWTiNrJuQP86r9kl+lCaSdpHabYTYtWrHltTw4yQDPibbmBLdnObQFoQ7iR4cm3gfBlP3QTGEH7skPQrAUdpPkxhzlMHUCiwjptfkujkcaFOYFw73Ypz0+msjAgxrhTtpLpGVqax0nTXym7K4aQUh1y9ZpoJFuyiVvIF6qT04Ohk4NavQhnIKdtXW6XnTbZ79+RGiF6sZeiqDQgGvngcueAwXDKiN0QMo8SHqCpka0Xnv0ZNWBKBPcYxKK2t6BIg0ZQQ4QSZ0KWHliJEnzSM6mtAdklAKnI040cE6VbP/UIKHooB2v7eJbEFPnSW1jl5VpRGak+9z3mgoWAJ/PcXLPuU8Vj3SD6klDaDCFhEqDITR/B0ONamf62eYmp4eYn+j3KjDvOaHlCaDWHrStPJm7UM+gvUpuowlyt7q+Y/c+vkPfJQZQ9vWy8ISvpLOPeIZgR689317D1LIGegb5DqCDOZp0euIUt8RhOmdR50Dw+xR5xqO9Wim3T0rHa8PDlNvVcHClmVHibSHiOnLyxY7ixU4I1IK+QV1AswpwNjH7wq0YoZdepCKGGEWLtdoZzgLSGhx3KlLCoJMi508aQACEKORlXucBy+6i66Sos3JuNARS5dO0tpeysWLXrO3w4FKZBSpuny8uIbYMOsamHu7t29OnixYHOBlIErKxEYr5q2QcxRo+wiaeI0S94AECo3bXNswLg+aYMFCC6TqBYc9zLHYLbaSSMVXjCLaWMylIuEiZ2sAwoBAna2tlF7eFxBbmQR6yjGBbrPlgPSPPJShVDuLBxhonF2cJr1fOLecAACU0SURBVLxvjJOvXgFIiszVzvY2HbwHbPHxYzTLOsOnr+MngWrS7JHbcL0PBuDe93bHnZY5J4uahaCp059Cyy0trdqf/p8/sV0Sbp1kR4OcTPxT+IXQuPsQGmH/+DueLtrFg9/7qb+ocm/78CDOSKaVGdYRon5A7CBSQYSW5HE4y0E/WRh8Bj+/E3VfGkcpYO2rnNX2F+vE2ak1dsFxMEuTF67ekvTo4eUcRbkZoYItMnDx6DhvFBDB8vA7cdmVodJiKd4v1jJInbBqQCO85y6cRYFCsn11CIpH2ryFN2z52artk3RSiHd4fMypYXFRTwPDA/bqN27yWS17ev+xTQ/2uWiijrBU+Dw5LmJhqp9vDGdxB8Jqv+eYBtZU+8FErqJFVDWjWv6RuVHXJ7CbKp3+OD6DchOYIalA1IV1w5nLEgnk0QgNYfk8Lw/lnqv9/Zf/Vp6e46N+AJoOoskhg9OThGDw7cH9JQXCS9RW/undz0hp91hPepjW7jCcMEk7J6cUeODbYPvFxNFBiWOH1UlczZ1qCF4vE0iUVHv6+R03cEro5gx8i1J236bTYXoLAb0X9miaJZvfsG2KbYocBvUh2j8CMm+o+3qPcxDrjXPSvWAhEHP8VFrJOVSeRi34ywiAUF43akfSixC0/QYN36D2obJjvrHpy7d0mmQ7tRDKKxfKMEkI3zojQ24TpPJ16lXLJyeQdeNDSTvScUMf2gwewSdMOgnUSJO88umcZ3mwwaHLgA4l21pfwzNmEPP1l8jEndlb737TXnvn6wBOhDSovPXn69ZFiNlBoqiEbRaHDlgA5whHSRuFMABPMb8ARw5nkH3k3nCeOPHqqhFBXeJZ2kRjx94YJlQq0wVsm/EuqFq1lU32QRLhOTKQWFoIjku0IBh88///fBmGyrzIg3b2E22SWXlGswiIqxwA+R4FtMHu6jMn5COzFx3VrBuoV/Ru5BHbS+iL3Rfn7wgo9pT1KLoEGhqGa6BDiW762IAa/MuUPbp/D+gdhk9nw5ZJwS8e7GFGyrZzQF+CMvgKkdfOftEJghjCTex/T2LAabICZfHiNUQpqhUhVWfGJYIQfCcA/O3gYhk7KT2eS8m+VkuQMRzH9o9kt1lMvYDFVoOFEMMc5K2qQkgLwRaw4DCBcls4KgHsESEVFUQVzykhinrRHdrqJrE/Tlkv1K/eLnWjoHM1ufoo9lTTMVJ9eOjOzwhBGFlj5h2tU7CXGtSkhgznQLIB1Ll68EfBCdhShiBjDlgMaQE/ztNuI2U3hrI2Cje/gN8g9ZoAJ8gTvlaSQL4HGUI+YufBEbtcXLMv0EZe0LgcTRwT0NvCFHae53QapMj0wF9pAnYOgWjTvlhkvG+BJ24gEyd5+bPbUMxSaIVj/BXZWUI5UEwvmIM01Q69llQupihDDpeERX6RwmW69cAQojaBQ6SaPQIdnUfXk/j+p7dtmojj9h1GvVxDdbcgd6rFDq8ooRnO0MYyr8oTKELQjZcprD2kl7PSvOU65trbz2FTLgHNxomX/6DP0DPKCXZf+rc+VD/kHvwk4jo7Zs03On3xlgME2CzXnIA354vbmAISEuF+hAGVyKYJTFB1Sam5TqqVKhPy+pph39cLwIF9JRJDMGCipogesIVJNUbihjf2sxaBAPLswec8POwUQCIvqmhtZQUJbFGhu+ecQf2sLHgTm+jnVHtZSAmdbFqeUieBGlpAwb2LGzhYqEbUAj6Lx2b6vFTBNGyDqZvb5NwbqOIAHTYOAFv281yTVrc5rtGFAEgtV8AH8vD5eWQ2WAKAGWHz9ceRMfiZfu7+jRAIK5FjG8BZ7U+PsjloQjReHL/Fh3Mo9nMBwdXsAOUoVE0l2FanVcid6wvsvgcEQ0WoWlqnU3w9+UQRzOoB/tBUj9kPXxqjXD5pY0DjA3RSieAoynwrYujvpe4CbzPHZ5zRvaSOqagBZsnzTya0V/Af0D4yUerrIA2jPZf2lkC1TYC+x6zq+RAsYgEFG3xJalDxbgHwRP0wg/Ul4dBBkfjkUatBGDjnhG28nI0mVmd4kQoNR6EfqYhUrVBzJJXkvPWQBh4iMbO8v2tjM7P28PYn1j80gTYQwkg3js1Vm7nyEk4Pdg+VeXR4aPmpaedJd8B8dbKLLaN6gdMgIqhcHkwLj7zHXMMNyrYDeNGfbgBG0S4lNXFOlEGqk5i4cgxpDu5AkhlI8hUKQpuknWA5L/+aqltOqCs8ldr/crO14V+dUj20np3/8a2+pzU899dBJu7KK6+zZmhMQjkV0OZ5Xje2DuHO01JHaXJ1SXNULiIRtc1VpKWDhELQ8nOgpG3YFu7j8OiItjlmlxkW5QH36EbFC3QKEmqrGbbWVJS7IwSsQBgrsEijZeIQRUUmPTg84CBQDMLs4RCxsPxb7aP6AegQ6f71jFL9/BNtzR2gGbStmAB+wcnUl5DAFilU7tOpuDoqX0BEhA2qEvJVqbZRebdsjQoduqFgiUp1TCXMFlm3TkiXcqCEZ/di2/sZNNHdH6bucMmKiVlQM/IHxNAj4xMsGHOEHtzjb1Qd2H4NB2lkdsotSJGFyW1SwMip0RwC9cXhidrCqO+BP7tCeNVxCh+L4OPSEKjAU/hz/QvD1o3tTXNv/ajTM3IRSzSzLnamaV5JGVw/o2YxRaocrkEU0RxCbbY2xa0cl29HCO2Fc2xiOVJoQg172F1+5sLAy6++Cdw76rqoZk+PYFDRPJJrqbhWzTB8mn3I5gm/l0kIIhSicikzKNOgndAmKRcQAAbv7aFfAP7Ffl5xP34Rp7eEja6Buop3KS3AIBzbqUHXRyqrJIzELdDIP4E7W3vLDIm+gBYGdmaN9EdOvdbOaQC0g4CftgS2PQE9tW906tItxf/yFuXx60+jKfWIaiP1oomgwu/zVLBUGnvYFy7KpkcoWBC3XNj0yADZQDY/BSo2QSOFEVRYP/RtaQhlF9M0Ol5aP4I61cPiYD8pqijydwEuYPYcoibaPUwjqgVy8J0wddS+NgUqFh+gFA3cOwgwEgKGDnCq5PCIP/vWjbRToetHvFlfbA5yQyq20wEn26KOs4AF6NddMIALMJ/z+AsjgEbnIIT7wMIqkFDCRs+sk99eNP2tdWgLgI6JNkyNKhzBgnUqIjiri9QgkkFM9aUZETfOaSSmRlh5J+qXdWFzJQzi/CmdLNNXhFFVRFso7a7vBSqtLz2hjjBis6NRu7HQZxm1d6V6Ogx/Mch9aKKr1L9X/hGj/WIQWLis9YQo5QN48tDBVWZSGrVwIm3HHaDapZGUeJIQOO2EH6UDzenlDqXWJPe8bnTm0i2pN8W/iheVx65CmoyT5o3iA0hNFSjmLFVoKU8uWalh0ZSlG/X9GSbgCEBEfgACz6XxOgkJqwiGctuqPlG8PsosnAKJnAxhzO7ODkUjYN8C07gR5RBUMXTw/AByxq4d7ZwQshEDY+tkpxVjJ6kj6EiGbXIqaVdfnrK+cTqFM127KqnG5gFQuD+CV0vq8hWnBCwxbPVuMpCYiZDqCWDcxBAoQhfber5FR3ScJrW8kQCwUPrjYdG1aBIIfuAEgeVx2kftY7R2Wi/pzLNjHF+KP44OdkHv4pYeGKOCijGyxOfSknJ41axRgJO6nOq6cqRFJJGdPoboUsgqhGvYj95EkDgEIoLkKDA5hc7dOTFD00n4FlimCE55kM0Lc6ASIWohiO+1Zs0yWpjScnKIrr1f1XcEyRS+JU5yC7jYq+YTaB2VoslZVQ/EdpaQA89/zgeQhAuZk1QqpevVwhInBqnwkfrIlpcRBHB3NjWOF+3hZs5Jx1bwUqM4QaI9HR6AUjHNMgomJCasslXneLOu5h8TMjCYtt+5krQbT57hucPrJ6d9e5keA2d+ijh7cSzhA5D7lspF71NBBFKFai9QU3DKJjUaWbs0FbWvv9TPgiHF5Pa/wCa3uNcx6hTFRnKxr04d9t4DmKSWKedk0IDtGJlCX0NSwh00DuihhEuaTSNyxfSVb/GVADh7r3/z1dYCnCJeo5+02FCFtBIYnZ4Ap0zvU0iYoQxNSOL0zIJNjs3YxMCEW+gqNruEGdJQTkUYwjyEKp4y0WT58Rf0O/Dab7w5atOjMKQR3io9fPpmJyy3tWnbi88tOTdjnYOQPzPbLtmjriVtBS5MRllZ0FqcyQgOqsrQVR2co15REUKLCKKzY9Ri9EdwfQ8xQ2E9A9qoAg6jFL/nxts/oh0OTB0upo2UR1wntKh51gnvRqiQxSR495wPoGySRrcIDZSH34fU90UpFePk71MIUYdIkiTcmwHPT8PCFdCq1Gyob5S0L1omu0exBpuMPeWjuY6PFC+n8aDg0L0zBKbMxmo2kUd8QZyfuL9iQ4kmnHqmYWK/PbRekWYRD+Hf/8+n9otn7QRLBMg4DErnw0tWKXSXCkGAe2N8L/OhZVPr+B5MRGZ5037144+sTN/8dptYmYCvNh31TTjlvGTe8+VPnQbQPzSiVQJQk1DyvevAxSFR6KWwUYsRw4YPpHvs0oVpMoIk0xit4zKdrEgVQchntxGaz1i/ur37tTmbnEjj2YNNkHbWMMo8OEMFJtXB5h6HCK01PAwtDhST+9K5lWoXaVeaRada7V80rCov7YcJUNz/VWsagg38BfYAQo6KcRUlKA1exZeoIJh+2Sg5eeLh6aGl2oLY8uyJiB87OC4wgGChNp1dke3AE8euiha+SXJn368hzhpn0mGjSeJRTtXn63u20knLN1Shv6PfLpPI6GAoYgnV5YevJrxKiRsFIYlewp2E4E76CaIO66Q0K6jsAH5EBP58FBaONFSDsKh6BOeNRa7hgzRBxSqQOSJoizKFGHWIGWcsvtKcWQTTA6LpkTZD1ao0S3iGmMIiap7RC7ABONPSaeJ08+B6LDYZiJQTC1JNDh8smy9563IA1UhKJkB+gF6jzQ6irSQgMgkCX5RVFckiCR6QoMvJb78IkgiF/ShPLQB8/xJrkSgf2/ScWeqb153PoLCwwDgXn+Buhj5HhyfQvOtEO5Bjg0OYRIRl8ZlNX79io0IL15YxPWL3ypy0zYoAN0HnOYAwNaU4x8FVBlHmV91E3ESUkz0m8YgtFUfzUVLX3KOpJtbQB9mgClNUk7x5bjZXsSyMIPrZZWnYFOpQzI+6If2r+cIxRsaGSZiEsb2qFezEGVS6VYOnURDcvNcuDw5ZjQjAO4FXSjPI8/UV20BThLh+F9dvDg8BqJCDTwyQi2cMuxbzFGHrQEDILAaHL1joYAXPH3WuU8X4E/XHAYZDaHQK3VGEJMLv+HGDkjHxGZSYUkfTFmVbGnYhk+BGziIURRJA52i3BE4b8kRq+JCNw5S4zed6kgA20a0BjqZKrXWSlGrlF+5/EhT9kcDzgQ4scr/gTbL3La6noFqKVIWYigaGKO9Od4HH87tql8a+0Vtgb4MNQuic+PBeQDUGMeDLAKbsrZtnaNL8lM/FENrJF+aBjo/s+ZNF9mXYxmYnSZkzZexQYa6GeNNeznVsRSuj4pP0LxRgdAb6eY5WkdsipDcOOaYMgCWSaNcwISbZ3nN6FPgLFer08OBbDUaVZymjQvZS2KUQzFTcLyuBCjaw2RUKO2IdxJ5UATnKEp61au1CCIfUoJyeGPZIk7UDqH/v0BhFg0fUAoBp0wCxwQ2FoHf7CCUreNGBNGZB4SfVxD6QLRiVVk8NQlbgmkfbaAPCQLSJFqXFxvqpEtKDEITIa2ShlVPX6eUkcIrEdXORDJvRQEB0KjmSvI4t4fsaOL74jNntTRZFI3KpiNJuc6Lb+8B28L2uIY2hn6lZNhfS/5zqbzuBX76en6k/n/MfdBn9W6/le2lRjB/hccWRO6SOm2xKfX/bakOzdA+nRAw4XJF5Az/CCSFC2wDfp3rDgBWtNX7JjIPTBCOIYs4ucC9LcBrUPGru2gLRAJpsO8PpbuP9aliJWefACKPBQWdP2hjOl04oPZYjSTX/gtTCC8/gWNQwtwiqpBxYsq9oWZ6ihace7SHOPCPUM3H+UE/c6BmdObLlAxoLslGo5SbARENawIseIRbPUYESgsDhF1sFj9t3+75VyONXuZkusmId+AYnGxk7kQ1XXnr90AEj3C3XQFUhSLVCBi3BBA5q8T2oUxEj6gnYqxsPaaiMtIo5zAprkcNopCKOKHrJbZwWUSbsqw3T6XL9CbR7pKnlgOl9quuXE6bX6T06+Tq17jTqe36uAgvnAEoK9BtUaVtT8E9+r9fry6l+p5P0LzxqTFBAPH8OQ5XTlgSUkmJR44waJrNFh5VWhkRYeoa6CBi6CDy6HLwKcuzGkrtfD9rVw7Etb4OUDk+ZD01QJLFUTMBHmAuQVt+zux98TkOKWQ4Ue1Xd5LNBFcFBsHhEOkDJctAxAbJlcjrrAcgmF9BAPO/2Lkk8nHeFrDIhYGyy52DpRRJBXVIpSAi17J4KN0JfHS8JF8Gg6mXnbfUCniA9QV5DqzYxhBgI5PrzedAggYoYLeT2GUIZg0ziT4ziLDJjgNyBCkBjVO2qZbxUs2yz0DjWyrUyb6pxEyps69Fjyz2lXpCbTVNPkKZFPXUsjrIl5k8YFaDN9HFaR5ItW3sGs1VeKF86jdooOWTaQFa0vUmSGp5ToJbAkLbNbm+ue6N+q813r2pvbls7tH8roRBLSqidXuSmnMh30GdwXSc0nGSfzAXCto7g/8vvQXiB2qWa/RKs6CbOMIgXsT1h8g41j/D3mnAZax0pytnIHMKcalCf4Jpqw3iq8NrSE3yb7j5rnfH8JOmi3RMWgECzv06b+aV18h3i/rG2vF98Sgy7KxDNK5vLqVYIPzLtJTTtgJ9AhpEhEmoSEYdAq7Ryjjjcc+G1cYeDtCndpEGRnE4AlxBETk39BNRESqSmQbL4MFUAK8kTxqaIbqTBUPdwqupi8YBodaFRkpAu1VkzkGKCFkvkAz0MQ3nygTKqYqaD9yihFMZjb6JufSOTFlx7bFFIpH5O0NLjLW42bze/cd2i5UMcE5ywKGPOuI6uJelVVk35gP/wB4/twboYTFwHgVS1i8yDvrQxEgb9rVY3eYCnXrqX6N/aOPc/t9/tjdRhEIiiXUZe3e/5f/eNQ+/4mcrBBC1r8/Vf+9c4ijh7cjrTI0M2ChliNHRic0NdOIFoKv4I72jiz4C8WRWPvkU9Yg1ASIymlnAM1tTLPfqILoTIyj8RmFTlb+UepA21oTw44TdTSOAQarJKHca0n5kF/toOggQrKtJklD29BHmGQAgafW/ENjdV0cUtaz244QKpZXVoU57CM39znL7FnE42JkxopZk03fDrSoQfeZoOpChj6iL2h9nBBtFgiYXHtBI5MFMQ5k2eWvfgxZedGq8s3kNj6MLC19snw3X64pou5uTTXccv3q+ByimyiOGpWaBVIokjsmn4D0MwI+49Wsf9AE0krx+jxjBAjX4rQmSAQ6l2qSHGn2oPBT799N6e/aef0IcHnDyG4+kDhnYbr53RA/M6Yfbn56SsoWflhQVw6iQoTvL1uq820u16e5EkBO0TruvoJYRc2E5dq40Y6jnBKpxQq5yMsJM/r10atGmq4c73V21+KMHCs2n8T+6K6vmKYCh+St68lJxVaFgZYT5zbHLGfYgbMp2nKRSOcR02knoLNWQScGhtmCJbpptVj9EKmMc8QlUlbcxdsJhN2ySczuNb+XJ0fgd5DMSUcGrYzi5EFg5EHL6lQscSkiA/J0IdRY7aCV9quOuWHkhhhbpccLjcCZOEgBfAikG1afQbJoAMPRtJCIeG8BLiZb3E2JdfplESnvzyY+isabKFODXkAZrYQHnxAk8sxZRQJLmF/fXEqUnDD1D4VekdwT5xY2tLRALYaBb5FAbtNn3uuiiqLJHKLQCLnqv8nO/zRCZlxqyD6Py/MEiZwsDQqyxwlYwaqpWT5peal9rXHvJcZxR99sNFHBwd5+f074OGLV9AnrvbZN4koKS99X9PALTxuoaEmlNfJW2tELDCaUfSSXPHQSjTrs+BxsVLK12eIKGDluKOGYCBiUS4+DFaE3xgZAICLeli/JlweoQyMgCp/kE2ctcVwBS3NqyO6SyQqSzj/FJA347tOf2V3S3zoD00DqcBiBQYHrMI5FYfTqJHVDi4m5akJtHTAyRP+Ty9GMpFyDFdI9wzkQLapp0Eaj+T1kndSn2pocQtATLiA8gpELmC5zQ/Uyx6Qz2WhO4VIPMUwOuXHdS0zAZp2zIqvvulV9qDj589YW5Rn9toL6Gc8P8qIItMZHCCnHOKVCUbHRgZJzyjjDzLTSd6LDgITPv0PvaLtCWvlYp8tLyPGgXZQ7bFDWzCc9MhaoielZ608i59ciB9FsEANHDSi+3LVOhPNH2REyZ8nUaJrtxMmkoduw7JAJKAmppDPhmlinmqoUFOQRFlNpwQ8Oy82gmA235uXIkalYGra4muWcG8yWOKEMql0lDY0qMOvRROomIZMaWUkbw01k03tWNL0PARd8flH86513MW+4S5fQU4C2dqF0MkIJqZO9XgH65FDCFwFYhcXTwbu9tuPVUpXcGvKit3sPGc6GC2nSjaWOOwERmDAipx5Oceg/yJz84DvNGXgM9L4Gy/fvNN6OpbrJW0lVBIfCr8siyFooqoPDM3RluKGyPk8E9FtSK86FVqkdMmr/YEBo51AAuT6g23kK7oqIXwTrvnF6z87JGVdjYdpVsImIRIqBsrhrbg4lKRqDJ5zcqCCfP3U5vn5RSEFl6w5vIj12ZNlDSxZLdp+SYMXAQTMWOVVg2i1v2cGBIMhF1isaKl9AdNpC2jSsFuZ6CEvfxDe7r40KVRT8Hoj8mvl7CV3X1Jm5xdgGA6g4+As8hDixW8eJ9WLmv0IaA+QACOMHohe3VOr9S8QlA5qRGiF3X8DovNi7ceB1HzoW6L5Ec0jaQGBoGaoQYwbBNDKXv7Wr99/OFnYCFuBdyiOzUiVSJ7xAbKcatCXPEkYECDCbg0PM5rAzVfgYYmOy8foLKzYa3JSwz0IHxU5MK8ZB+a1bfwolW31qyOVrCJOajvOJIP77bNL+9rTMzjm3XYJCDQ9OgQTSU37Gd/8wv6OHFwjPAadDUHYIRTYJ7pG2POlxFm76n4gXbpEqbTyk1oAcqclhqsUzWP7oD+3DFz1ToAcvJ8YJVmCPFrLwGbgbXLXnCT2hj5CWW0Qovfd1y97hBBLyheHTOhTQhffonZQMDCB5tfevAQQ9SDEHWdIvGD/+rWyt9DgyXE1MuDu2szmkU9CLSOUvH6XkWad1TBHJ9xXLt1+IYqitAGFsm2RUAYB8bGXW5ep929jxj5nLKvxbt3bHOZWFuRCSdYvD+XxwfVVDm7KnyUxlVkEQVBi+CHHOxmbB/2rkbOqrM4N+DCT+TKcR5euThkdrxiCyN4/vxM++58BrD6Jva8Djbrn7ti59DPK1DJm6jvFkimEEzNJwrNX2Zz17lPDkyBAdloWxudszKevwcCipp3lmEmGxqvhZao4ku04F2of6JMqE55iJTwi99+j85ik7SWz8CELtMtbM9++csPLIMG3S0iWBxG0QA8U9cRAC0o//V3UgfAKSvSCDkaBLHCM+0g9FIzxRaL5huethDl0Odf3ENYAHbGpwjt0nTdxtFg8aRyFSL56A5Sg+wRnVvA5ieI82hU+PQBUQJOx/gceQVu9PlTgvJ2oWkdsyAW0RCj0BRzC8TQgoRGZ6yZWSE8JIOHWLSYMMLKgPeDQ6BlSAnYPiVOwYkb9t//x5/aG299k5Q1UCiCp4aX6o0ripY6eanEuj3riGtzn8Lmxe8TVb0E8udq/bmuNlw4gISsgUde5oRHUckVYNYlNm0PNay+RVonOc8aLBUGXlb0oixbEhDtu5eo7JFWQQKa+BoNqoFr3G+LxpMNoFwvWtGXJC298gy7Pm1VNkU+ibRgCYGKorGKTx5zv0D0nPg6z+EbnrTcOhiCVBgObw2fJDQzb1WAtvoxPEf2T1FCO3GF84wQ33j7bZu88oJlNjbtwz//c0r9M+7357C5C/A5K26IpaSUL57X2SENkFRZmLx35aH3KVhoQaHyyGk6fkC7dhaLhI46gbTohOkHp+Yg8uGoT5wNddRUalnh4eE9qmy5hrh8/MjCAB9+0b7pjiHHKMoGN9is491di9ParUyOQV+yh15qEirbOzigaAAyd63jPVQ+j4lK9tKTsIGzCO5EG5sB5hKl8GrD9vHP/9Leev83HQijIU9aK22k/nYMHKBsR8jA5LjuY0Q+Y/Oz0uDu+YUFSP0rJV7G51C+XhS1I07PF7/6O2BrWsdyCpXnV4PKyfmLaLQIPhOmhmYar02F7Ak4hoo/vZDydL0GG9gYAhrfxbab8iPUDx6IbxHB4QNpXd/gkHSjkeinKP+E+65tAYuz4SeL9Ghi4Zqobn9zw0IDtMBBCJpERU1S9gV+HxifJD8CbM37WQTnaAvDyOFPffizn1Fo27SPSH3vEIUEKDptls7wm6Dw+VWSh0bt6ovdErbuY+ixj5rxmLfL1QbolPipEfCQ4Ol75Q26fmDJUVW+wTHrfOElZ9tDeP1+bspLV00fKjJI3B8aHLEOQrvw2JRFh/B0R0hHwnztoDmkTop3Z83Zy47ZOWtCqIjRH0in+pRqHxU9aLiUiJ5F7GyBUq4K3ETVExShnen3dRylOrCul40i/2Gd6XH67FBFBGFi59ljnLsDG5294Lx1baQcW6lwoYRtGpYyZcqicS0WTnG9IF61kFeqV1NC1FxZHL8wqnR/myZSH/4tgqh2rPTZxbRc++bb9v3f+4ckjBgje6zJYpRlKQzFTL5ycYCOYNv4UECtaAoxnquAaQ0SMWUAIvEeNcmzrOomDw4cFDJFR3J0BeMSS1qJU+1laolmATQQVj/rWNrcwHajFfv6gdKpNcSpLWGGakegs5SnK2+h5wkOj5qP6MKL4OLF2P7yEoysx4T3ZC4h56otoB/B1H8lKOW+vt7eW120bUvQMiTMTboTw/+1iKcbePZdSHkzs4WkruLJE8LQ2q1OvAkZzgE0HpI7NcIWUa110jURQ5Wsdex2HUepQY+9Ku9t4pg1uFl0PAWJY8T/U1Zef06YWXapUNX4u1mD+BD+gTEenEhCRZqIc52TqI2skSbGoXbhoZJE+yQzRi5cth3QsTDZQnXeON58DpnkiJBvwm1szQmBMnrK/SuW57SgqeQ51zFNyoZKndYEO0sI8GVE+5YKzx4d2heffCDn35Kc/DNe987v/ra99w++D58/Q7SB0PJ6aRtdSzP/Lk0kycgBOiFAStawL5xWtGovWgCBjlyERs6BKFHkogSR8hg1TKgP01rmFKuCSK8vEaWEODzlI5w2zKuPuQWVVfwAagy9mLQ8aj08QVioQ8J9+tiXBgyn+smx+32T6EGCXuL5fOyVyuQrAE0V/IUWgldlraqYAN/U2IVbQfLF0pRKgypn3hKcC1jRMT6BLXpI8+eM+ThpXhyjBlU/aG9eB2gEgNPgBjQkSX1zpIp081V+Jg+zSShIygmhQqD4z8cmN9Asel99Feyb16q3nTJnDlXUTSg0Q617Sf6o7ap666vMW9M4Qqh/jbhXvkB+QrEVsrGLV2yN6KEEdDowNgkWQIiFw3nI6ehWnCxbzSK4sI7P+/unXmxfbbyQPYV8agVfEjpHKCzH7QkkVtHGukgNF9jJm+9/z66//iq9A3edambvnAApIlChi1i5vaCnfQmaaK+wZjw3yTpCMEAZ8vu4f3QqWWH2IoeBz1CLfd1XS5qCi3lwWGsUzii1LF9IvlVkZMzKmySwEDIJTAVzFEQL4Ca76EC2v8W919kX/5dCUOHAKkZSdzM5ll5a3+HMgO5yetDqgbFZ1gRzTYLON5QeuaV1V+tVeZ7yVH2ADB04d7n7dx3wEJm7xIdSkMhrwqh8uiVg4yEx8tBebtqDd6z3eghHlBINcCMegKIGZEmpd32uS5fqlM9ccP4AnDAiDNHNimw+HjIvUq7dx+lnFwgPMQ18fQVSycFyQAYb5v4GSCqh+oenL9gGnbLOkej+AfLtbHpmaxs2Uc4OMxlyCDEiAXKcbL5OhFBKtZ93p5/718ZJ/UsLVFkk0cZV6KrqpZOdde4NRI77n71xEyradULIY9f2RWRPIW5S8XkgWF1PWkr5kfkhmmk/3nFIXAm1rpL1KhvoxQmscrIb2jDUsNaFt7jN1M+0OXKotKHIlhM++UOBbqaqsU7dN151QlNYWabgZgSh4Dr8vIWzyI1zsNAkaCpFNSprC2Ia/PgXTZ7HS4ZWh9cHHF5DowbTmArMjTf4VR5dnjffa6J2fJj+uJ/fYeImThAwZRjKVjlDrQCsGwyksyE11I7IGT687abSuzgwTW5IPf7VorW+s0mdnDxuhZP4FyyYSsPON0n1AmFKMKSetJBKXzrbg3ryMUqmRagjakoTFapULm4gfyOcX/0b7YO3yelWQQr6WZ+JFtGJuXhlwS6zWX487Riqc3eJfkDY6QilZF/NGZTT6pad97rCD6en9SmYH66teQHneNZRbLMSTXGEf3yajiK0fXGHhXe7lngsrLCKsIgvrJ9+p/oDtsz17xMXQRiH+x1CJtJNALhYpev6ubSTQDP9LWwAm0LqN0m/JeY04WirZtCDGfRxfdVW5nEwdbKFOMp8dk0S3uLIhjhw0fEZwChQEsxxlPsVnbxCxFEhpdxiL2hWzOcQ3fC5QZDY5jbC3T9k/xcM6QbO42LQjAAAAABJRU5ErkJggg=="; @@ -147,6 +148,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ "Birch", "Dune", "Fern", "Moss", "Reed", "Slate", "Ash", "Brook", "Glen", "Stone", ], model: Some("claude-sonnet-4-20250514"), + provider: Some("claude"), }, BuiltInPersona { id: "builtin:kit", @@ -295,6 +297,7 @@ Don't present work that doesn't meet this bar. No emojis. Your name is Kit. You are friendly and helpful. You are understated, but have a sense of humor."#, model: Some("claude-sonnet-4-20250514"), + provider: Some("claude"), }, BuiltInPersona { id: "builtin:scout", @@ -454,6 +457,7 @@ If you're invoked outside a Sprout team channel (or by an agent that isn't Kit), Your name is Scout. You are friendly and helpful. You are understated, but have a sense of humor."#, model: Some("claude-sonnet-4-20250514"), + provider: Some("claude"), }, ]; @@ -469,7 +473,7 @@ fn built_in_persona_records(now: &str) -> Vec { display_name: persona.display_name.to_string(), avatar_url: persona.avatar_url.map(|s| s.to_string()), system_prompt: persona.system_prompt.to_string(), - provider: None, + provider: persona.provider.map(|s| s.to_string()), model: persona.model.map(|s| s.to_string()), name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, @@ -519,6 +523,9 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec Date: Thu, 14 May 2026 21:05:10 -0400 Subject: [PATCH 03/17] fix: CLI-first base prompt and remove hardcoded model from built-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base_prompt.md now leads with the sprout CLI (12 subcommand groups from #585) rather than MCP tools, which are still available but secondary. Adds the @mention formatting rule (no bold/italic), get_feed type filtering, and a pointer to nest_agents.md for workspace conventions. Reverts the hardcoded model/provider on Solo, Kit, and Scout back to None. The agent binary's own default is the right choice here — pinning claude-sonnet-4-20250514 is opinionated and will go stale. Users who want a specific model can set it per-agent via the model picker. --- crates/sprout-acp/src/base_prompt.md | 61 ++++++++++++------- .../src-tauri/src/managed_agents/personas.rs | 12 ++-- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md index b9a0a4908..5f82e213e 100644 --- a/crates/sprout-acp/src/base_prompt.md +++ b/crates/sprout-acp/src/base_prompt.md @@ -1,35 +1,52 @@ -You are operating inside the Sprout platform — a Nostr-based messaging platform for human-agent collaboration. The sprout-acp harness bridges channel events to your session. +You are operating inside the Sprout platform — a Nostr-based messaging platform for human-agent collaboration. The sprout-acp harness routes channel events to your session. -## MCP Tools (via `sprout-mcp`) +## Sprout CLI -- `get_messages(channel_id, limit=50)` — fetch recent history (max 200 per call) -- `get_messages(channel_id, since=)` — fetch messages since timestamp; returns oldest-first when `since` is set without `before` -- `get_thread(channel_id, event_id)` — fetch a full thread by root event ID -- `get_feed()` — personalized feed of mentions and needs-action items across all channels -- `send_message(channel_id, content)` — post a new message to a channel -- `send_message(channel_id, content, parent_event_id)` — reply within an existing thread -- `search(q="your query")` — cross-channel full-text search +The `sprout` CLI is your primary interface. Auth env vars: `SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`, `SPROUT_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON — pipe through `jq` as needed. + +| Group | Key commands | +|-------|-------------| +| `sprout messages` | `send`, `get`, `thread`, `search` | +| `sprout channels` | `list`, `get`, `create`, `join`, `members` | +| `sprout canvas` | `get`, `set` | +| `sprout reactions` | `add`, `remove` | +| `sprout dms` | `list`, `open` | +| `sprout users` | `get`, `set-profile`, `presence` | +| `sprout workflows` | `list`, `trigger`, `runs` | +| `sprout feed` | `get` | +| `sprout social` | `publish`, `notes` | +| `sprout repos` | `create`, `get`, `list` | +| `sprout upload` | `file` | + +Run `sprout --help` or `sprout --help` for full usage. + +MCP tools (via `sprout-mcp`) are also available but the CLI is preferred for batch operations and scripting. ## Communication Patterns -- Address agents and humans with `@name` in message content. -- Use `parent_event_id` when responding to a thread; post a new message for new topics. -- There are no push notifications — poll for new messages using `since=`. +- Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery). +- Use `sprout messages thread` or MCP `get_thread()` when responding in-thread; post new messages for new topics. +- No push notifications — poll with `sprout messages get --since=` or MCP `get_messages(since=)`. When `since` is set without `before`, results are oldest-first (chronological). ## Startup Recovery -On startup or after a gap: call `get_feed()` first to surface pending mentions and action items, then call `get_messages` on your assigned channels to catch up, then check `AGENTS.md` for team context. Use `search()` for cross-channel keyword lookups when you need to find specific prior discussions. +1. `sprout feed get` (or MCP `get_feed()`) — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. +2. `sprout messages get ` on assigned channels — catch up on recent history. +3. Check `AGENTS.md` in your working directory for team context. +4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `sprout messages search --query "..."` for cross-channel keyword lookups. ## Workspace Layout -Your persistent workspace is in your working directory, with the following subdirectories: +Your persistent workspace is in your working directory: -- `RESEARCH/` — findings and reference material -- `PLANS/` — project and task plans -- `GUIDES/` — how-to documentation -- `WORK_LOGS/` — timestamped activity logs -- `OUTBOX/` — drafts pending review or send -- `REPOS/` — checked-out source repositories -- `.scratch/` — ephemeral working files +| Dir | Purpose | +|-----|---------| +| `RESEARCH/` | Findings and reference material | +| `PLANS/` | Project and task plans | +| `GUIDES/` | How-to documentation | +| `WORK_LOGS/` | Timestamped activity logs | +| `OUTBOX/` | Drafts pending review or send | +| `REPOS/` | Checked-out source repositories | +| `.scratch/` | Ephemeral working files | -Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming and YAML frontmatter. `AGENTS.md` in the working directory lists active agents and their assigned roles. +Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `nest_agents.md` in your working directory for full workspace conventions. diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index e7bb0318e..2bd74c3bc 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -147,8 +147,8 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ "Atlas", "Ember", "Flint", "Sage", "Drift", "Quill", "Wren", "Cedar", "Pike", "Lark", "Birch", "Dune", "Fern", "Moss", "Reed", "Slate", "Ash", "Brook", "Glen", "Stone", ], - model: Some("claude-sonnet-4-20250514"), - provider: Some("claude"), + model: None, + provider: None, }, BuiltInPersona { id: "builtin:kit", @@ -296,8 +296,8 @@ Aim for 9/10+ on the first pass. There is no separate refactoring pass — if it Don't present work that doesn't meet this bar. No emojis. Your name is Kit. You are friendly and helpful. You are understated, but have a sense of humor."#, - model: Some("claude-sonnet-4-20250514"), - provider: Some("claude"), + model: None, + provider: None, }, BuiltInPersona { id: "builtin:scout", @@ -456,8 +456,8 @@ You are read-only, but you still resolve questions yourself before pinging Kit: If you're invoked outside a Sprout team channel (or by an agent that isn't Kit), apply the same protocols. Default to FULL REVIEW for completed work or PLAN REVIEW + RESEARCH for plans. Report to whoever invoked you. Your name is Scout. You are friendly and helpful. You are understated, but have a sense of humor."#, - model: Some("claude-sonnet-4-20250514"), - provider: Some("claude"), + model: None, + provider: None, }, ]; From ce47e4595eb92738283c038d1e63cfeb8306c469 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:48:33 -0400 Subject: [PATCH 04/17] feat(desktop): dynamic nest AGENTS.md regeneration AGENTS.md in ~/.sprout is now dynamically regenerated whenever personas, agents, or workspace config changes. Agents discover their teammates on every fresh session via a managed section demarcated by HTML comment markers. User edits outside the markers are preserved across regenerations. --- .../src-tauri/src/commands/agent_models.rs | 9 +- desktop/src-tauri/src/commands/agents.rs | 23 +- desktop/src-tauri/src/commands/personas.rs | 26 +- desktop/src-tauri/src/commands/workspace.rs | 10 +- desktop/src-tauri/src/lib.rs | 7 +- desktop/src-tauri/src/managed_agents/nest.rs | 245 ++++++++++++++++++ .../src/managed_agents/nest_agents.md | 7 + 7 files changed, 313 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 55b6207b4..50986b4d4 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -8,8 +8,9 @@ use crate::{ managed_agents::{ build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, load_managed_agents, managed_agent_avatar_url, missing_command_message, - normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, + normalize_agent_args, regenerate_nest_context, resolve_command, save_managed_agents, + sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -246,6 +247,10 @@ pub async fn update_managed_agent( (summary, sync_params) }; // lock dropped here + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + // Phase 2: relay profile sync (async, best-effort, outside lock) let profile_sync_error = if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 7e663ed41..1f984b0ba 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -7,12 +7,13 @@ use crate::{ build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, - normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, - ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, + normalize_agent_args, provider_deploy, read_log_tail, regenerate_nest_context, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, + BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + DEFAULT_MCP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -452,6 +453,10 @@ pub async fn create_managed_agent( (agent, spawn_error) }; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── let avatar_url = input .avatar_url @@ -713,7 +718,11 @@ pub fn delete_managed_agent( if records.len() == initial_len { return Err(format!("agent {pubkey} not found")); } - save_managed_agents(&app, &records) + save_managed_agents(&app, &records)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index f5ece7f01..531242996 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -7,7 +7,7 @@ use crate::{ managed_agents::{ encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents, load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona, - parse_zip_personas, save_managed_agents, save_personas, + parse_zip_personas, regenerate_nest_context, save_managed_agents, save_personas, uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest, @@ -85,6 +85,9 @@ pub fn create_persona( }; personas.push(persona.clone()); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(persona) } @@ -134,6 +137,9 @@ pub fn update_persona( persona.updated_at = now_iso(); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } personas .into_iter() .find(|record| record.id == input.id) @@ -182,6 +188,9 @@ pub fn delete_persona( if changed_agents { save_managed_agents(&app, &agents)?; } + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(()) } @@ -230,6 +239,9 @@ pub fn set_persona_active( let updated = persona.clone(); save_personas(&app, &personas)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } Ok(updated) } @@ -383,7 +395,11 @@ pub fn install_persona_pack( if !source.is_dir() { return Err(format!("pack path is not a directory: {path}")); } - import_persona_pack(&app, &source) + let result = import_persona_pack(&app, &source)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(result) } #[tauri::command] @@ -396,7 +412,11 @@ pub fn uninstall_persona_pack( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - do_uninstall_persona_pack(&app, &pack_id) + do_uninstall_persona_pack(&app, &pack_id)?; + if let Err(error) = regenerate_nest_context(&app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } + Ok(()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index e75d1801d..615dd2324 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,8 +1,9 @@ use nostr::Keys; use serde::Serialize; -use tauri::State; +use tauri::{AppHandle, State}; use crate::app_state::AppState; +use crate::managed_agents::regenerate_nest_context; use crate::relay; #[derive(Serialize)] @@ -30,6 +31,7 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result, + app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { // ── Validate before mutating ────────────────────────────────────────── @@ -51,5 +53,11 @@ pub fn apply_workspace( *keys_guard = keys; } + if let Err(error) = regenerate_nest_context(&app) { + eprintln!( + "sprout-desktop: failed to regenerate nest context after workspace switch: {error}" + ); + } + Ok(()) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ffc3efafb..2591f0dc5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ use huddle::{ speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - ensure_nest, kill_stale_tracked_processes, load_managed_agents, + ensure_nest, kill_stale_tracked_processes, load_managed_agents, regenerate_nest_context, restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; @@ -403,6 +403,11 @@ pub fn run() { } } + if let Err(error) = regenerate_nest_context(&app_handle) { + eprintln!("sprout-desktop: failed to regenerate nest context: {error}"); + } + + // Pre-download voice models in the background so they're ready // when the user starts their first huddle. Idempotent — no-op if // already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS). diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 25639b8d9..28f2eb045 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -6,8 +6,15 @@ //! //! Idempotent: existing files and directories are never overwritten. +use super::{ + load_managed_agents, load_personas, BackendKind, ManagedAgentRecord, PersonaRecord, RespondTo, +}; +use crate::app_state::AppState; +use crate::relay::relay_ws_url_with_override; use std::fs; +use std::io; use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; /// Subdirectories created inside the nest. const NEST_DIRS: &[&str] = &[ @@ -224,6 +231,107 @@ pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { Ok(()) } +const CLI_QUICK_REFERENCE: &str = "\ +## CLI Quick Reference +`sprout messages send --channel --content ` — send a message +`sprout messages get --channel ` — read recent messages +`sprout channels list` — list available channels +`sprout workflows trigger --workflow ` — trigger a workflow +Run `sprout --help` for the full command reference."; + +pub fn render_dynamic_section( + personas: &[PersonaRecord], + agents: &[ManagedAgentRecord], + relay_url: &str, +) -> String { + let active_agents = if agents.is_empty() { + "## Active Agents\n\n*(No agents deployed yet. Add agents in the Sprout desktop app.)*" + .to_string() + } else { + let mut table = + "## Active Agents\n\n| Name | Role | How to address |\n|------|------|----------------|" + .to_string(); + for agent in agents { + let role = agent + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.display_name.as_str()) + .unwrap_or("—"); + table.push_str(&format!( + "\n| {} | {} | @{} |", + agent.name, role, agent.name + )); + } + table + }; + + format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}\n\n{CLI_QUICK_REFERENCE}") +} + +pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io::Result<()> { + let current = fs::read_to_string(file_path)?; + + const BEGIN: &str = ""; + + let replacement = format!( + "\n{new_section_content}\n\n" + ); + + let new_content = + if let (Some(begin_pos), Some(end_pos)) = (current.find(BEGIN), current.find(END)) { + // Find the start of the BEGIN marker's line. + let line_start = current[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + // END marker spans to the end of its content + the newline after it. + let end_of_end = end_pos + END.len(); + let after_end = if current[end_of_end..].starts_with('\n') { + end_of_end + 1 + } else { + end_of_end + }; + format!( + "{}{}{}", + ¤t[..line_start], + replacement, + ¤t[after_end..] + ) + } else { + format!("{}\n\n{}", current.trim_end_matches('\n'), replacement) + }; + + let tmp_path = file_path.with_extension( + file_path + .extension() + .map(|e| format!("{}.tmp", e.to_string_lossy())) + .unwrap_or_else(|| "tmp".to_string()), + ); + fs::write(&tmp_path, new_content)?; + fs::rename(&tmp_path, file_path)?; + + Ok(()) +} + +pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + let agents_md = nest.join("AGENTS.md"); + + if !agents_md.exists() { + return Ok(()); + } + + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + let state = app.state::(); + let relay_url = relay_ws_url_with_override(&state); + let content = render_dynamic_section(&personas, &agents, &relay_url); + upsert_managed_section(&agents_md, &content) + .map_err(|e| format!("regenerate nest context: {e}"))?; + + Ok(()) +} + + #[cfg(test)] mod tests { use super::*; @@ -406,4 +514,141 @@ mod tests { // in the Ok(_) branch of ensure_cli_symlink skips regular files). assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary"); } + + fn make_persona(id: &str, display_name: &str) -> PersonaRecord { + PersonaRecord { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + provider: None, + model: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_pack: None, + source_pack_persona_slug: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + acp_command: String::new(), + agent_command: String::new(), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + mcp_toolsets: None, + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_pack_path: None, + persona_name_in_pack: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + } + } + + #[test] + fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("## CLI Quick Reference")); + } + + #[test] + fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); + assert!(output.contains("No agents deployed yet")); + } + + #[test] + fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); + assert!(output.contains("| Scout | — | @Scout |")); + } + + #[test] + fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); + } + + #[test] + fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find(" +## Active Agents + +*(No agents deployed yet. Add agents in the Sprout desktop app.)* + + From 8d5577f0c62b2adec4e29876be3b6b0c8b2611d7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:50:35 -0400 Subject: [PATCH 05/17] fix: gate test-only imports behind #[cfg(test)] in nest.rs BackendKind and RespondTo are only used in test helper constructors. --- desktop/src-tauri/src/managed_agents/nest.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 28f2eb045..65c704194 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -6,9 +6,9 @@ //! //! Idempotent: existing files and directories are never overwritten. -use super::{ - load_managed_agents, load_personas, BackendKind, ManagedAgentRecord, PersonaRecord, RespondTo, -}; +use super::{load_managed_agents, load_personas, ManagedAgentRecord, PersonaRecord}; +#[cfg(test)] +use super::{BackendKind, RespondTo}; use crate::app_state::AppState; use crate::relay::relay_ws_url_with_override; use std::fs; From ef5765cb86605d493ae260f3296e44f270e9da33 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 16:23:15 -0400 Subject: [PATCH 06/17] fix(desktop): address review findings for nest AGENTS.md regeneration Fixes identified by crossfire review (Codex + Gemini) and plan author: - Use tempfile::NamedTempFile for atomic writes instead of deterministic .tmp path that races under concurrent regeneration triggers - Enforce ordered BEGIN/END marker search with line-start anchoring to prevent inverted slicing when markers are out of order or mid-line - Strip orphan BEGIN markers before appending new managed section - Escape pipe and newline characters in agent/persona names to prevent Markdown table corruption - Rename "Role" column to "Persona" (display_name is a name, not a role) - Move regenerate_nest_context calls outside lock scope in all mutation hooks to reduce lock hold time and eliminate future deadlock risk - Add 7 adversarial unit tests: marker ordering, orphan cleanup, duplicates, code-block false positives, pipe/newline escaping, idempotency --- desktop/src-tauri/src/commands/agents.rs | 76 ++--- desktop/src-tauri/src/commands/personas.rs | 12 +- desktop/src-tauri/src/managed_agents/nest.rs | 317 ++++++++++++++++--- 3 files changed, 325 insertions(+), 80 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 1f984b0ba..c2f89ae1e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -677,48 +677,50 @@ pub fn delete_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; - if sync_managed_agent_processes(&mut records, &mut runtimes) { - save_managed_agents(&app, &records)?; - } + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } - // Guard: reject deletion of deployed remote agents unless explicitly forced. - // This turns "don't orphan remote infra" from a UI convention into a backend - // invariant — a buggy or compromised IPC caller cannot silently orphan a live - // remote deployment. The frontend sends force_remote_delete: true only after - // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { - if record.backend != BackendKind::Local - && record.backend_agent_id.is_some() - && !force_remote_delete.unwrap_or(false) - { - return Err( - "cannot delete a deployed remote agent without force_remote_delete: true" - .to_string(), - ); + // Guard: reject deletion of deployed remote agents unless explicitly forced. + // This turns "don't orphan remote infra" from a UI convention into a backend + // invariant — a buggy or compromised IPC caller cannot silently orphan a live + // remote deployment. The frontend sends force_remote_delete: true only after + // the user confirms the orphan warning. + if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + if record.backend != BackendKind::Local + && record.backend_agent_id.is_some() + && !force_remote_delete.unwrap_or(false) + { + return Err( + "cannot delete a deployed remote agent without force_remote_delete: true" + .to_string(), + ); + } } - } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - // For local agents: kills the process. For remote agents: no-op (the frontend - // sends !shutdown via WebSocket before calling delete). Either way, safe. - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + // For local agents: kills the process. For remote agents: no-op (the frontend + // sends !shutdown via WebSocket before calling delete). Either way, safe. + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + let initial_len = records.len(); + records.retain(|record| record.pubkey != pubkey); + if records.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + save_managed_agents(&app, &records)?; } - save_managed_agents(&app, &records)?; if let Err(error) = regenerate_nest_context(&app) { eprintln!("sprout-desktop: nest context regeneration failed: {error}"); } diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 531242996..39db8a987 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -128,22 +128,20 @@ pub fn update_persona( .filter(|s| !s.is_empty()) .collect(); if let Some(env_vars) = input.env_vars { - // Caller explicitly sent env_vars — replace entirely (empty = clear). crate::managed_agents::validate_user_env_keys(&env_vars)?; persona.env_vars = env_vars; } - // Absent env_vars means "don't touch" — preserve existing creds when - // the caller only meant to edit a different field. persona.updated_at = now_iso(); save_personas(&app, &personas)?; + let result = personas + .into_iter() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; if let Err(error) = regenerate_nest_context(&app) { eprintln!("sprout-desktop: nest context regeneration failed: {error}"); } - personas - .into_iter() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id)) + Ok(result) } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 65c704194..fe6497a37 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -35,6 +35,8 @@ const AGENTS_MD: &str = include_str!("nest_agents.md"); /// Written to ~/.sprout/.claude/skills/sprout-cli/SKILL.md on first init. const SPROUT_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); +const BEGIN_MARKER: &str = ""; /// Returns the nest root path (`~/.sprout`), or `None` if the home /// directory cannot be resolved. pub fn nest_dir() -> Option { @@ -239,6 +241,10 @@ const CLI_QUICK_REFERENCE: &str = "\ `sprout workflows trigger --workflow ` — trigger a workflow Run `sprout --help` for the full command reference."; +fn escape_md_cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + pub fn render_dynamic_section( personas: &[PersonaRecord], agents: &[ManagedAgentRecord], @@ -249,7 +255,7 @@ pub fn render_dynamic_section( .to_string() } else { let mut table = - "## Active Agents\n\n| Name | Role | How to address |\n|------|------|----------------|" + "## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|" .to_string(); for agent in agents { let role = agent @@ -258,10 +264,9 @@ pub fn render_dynamic_section( .and_then(|pid| personas.iter().find(|p| p.id == pid)) .map(|p| p.display_name.as_str()) .unwrap_or("—"); - table.push_str(&format!( - "\n| {} | {} | @{} |", - agent.name, role, agent.name - )); + let name = escape_md_cell(&agent.name); + let role_escaped = escape_md_cell(role); + table.push_str(&format!("\n| {name} | {role_escaped} | @{name} |")); } table }; @@ -269,45 +274,80 @@ pub fn render_dynamic_section( format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}\n\n{CLI_QUICK_REFERENCE}") } +/// Find a marker that appears at the start of a line (position 0 or preceded by `\n`). +fn find_marker_at_line_start(content: &str, marker: &str) -> Option { + let mut search_from = 0; + while let Some(pos) = content[search_from..].find(marker) { + let abs_pos = search_from + pos; + if abs_pos == 0 || content.as_bytes()[abs_pos - 1] == b'\n' { + return Some(abs_pos); + } + search_from = abs_pos + 1; + } + None +} + +/// Find the first valid ordered BEGIN/END marker pair, both at line starts. +/// Returns `(begin_line_start, after_end)` byte offsets for slicing. +fn find_managed_markers(content: &str) -> Option<(usize, usize)> { + let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?; + let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + let end_pos = content[begin_pos..].find(END_MARKER).map(|p| p + begin_pos)?; + let end_of_end = end_pos + END_MARKER.len(); + let after_end = if content[end_of_end..].starts_with('\n') { + end_of_end + 1 + } else { + end_of_end + }; + Some((begin_line_start, after_end)) +} + +/// Remove an orphan BEGIN marker line (one with no matching END after it). +fn strip_orphan_begin_marker(content: &str) -> String { + if let Some(pos) = find_marker_at_line_start(content, BEGIN_MARKER) { + let line_start = content[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0); + let line_end = content[pos..].find('\n').map(|p| pos + p + 1).unwrap_or(content.len()); + format!( + "{}{}", + &content[..line_start], + content[line_end..].trim_start_matches('\n') + ) + } else { + content.to_string() + } +} + pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io::Result<()> { let current = fs::read_to_string(file_path)?; - const BEGIN: &str = ""; - let replacement = format!( - "\n{new_section_content}\n\n" + "{BEGIN_MARKER} — regenerated automatically, do not edit below -->\n{new_section_content}\n{END_MARKER}\n" ); - let new_content = - if let (Some(begin_pos), Some(end_pos)) = (current.find(BEGIN), current.find(END)) { - // Find the start of the BEGIN marker's line. - let line_start = current[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - // END marker spans to the end of its content + the newline after it. - let end_of_end = end_pos + END.len(); - let after_end = if current[end_of_end..].starts_with('\n') { - end_of_end + 1 - } else { - end_of_end - }; + let new_content = match find_managed_markers(¤t) { + Some((begin_line_start, after_end)) => { format!( "{}{}{}", - ¤t[..line_start], + ¤t[..begin_line_start], replacement, ¤t[after_end..] ) - } else { - format!("{}\n\n{}", current.trim_end_matches('\n'), replacement) - }; - - let tmp_path = file_path.with_extension( - file_path - .extension() - .map(|e| format!("{}.tmp", e.to_string_lossy())) - .unwrap_or_else(|| "tmp".to_string()), - ); - fs::write(&tmp_path, new_content)?; - fs::rename(&tmp_path, file_path)?; + } + None => { + let cleaned = strip_orphan_begin_marker(¤t); + format!("{}\n\n{}", cleaned.trim_end_matches('\n'), replacement) + } + }; + + let parent = file_path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "file path has no parent directory") + })?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + { + use std::io::Write; + tmp.write_all(new_content.as_bytes())?; + } + tmp.persist(file_path).map_err(|e| e.error)?; Ok(()) } @@ -576,6 +616,7 @@ mod tests { let agents = vec![make_agent("Kit", Some("p1"))]; let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); assert!(output.contains("## CLI Quick Reference")); } @@ -645,10 +686,214 @@ mod tests { upsert_managed_section(&file, "content").unwrap(); - let tmp_path = file.with_extension("md.tmp"); + // Verify no stray temp files in the directory + let entries: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1, "only AGENTS.md should remain, no temp files"); + assert_eq!(entries[0].file_name(), "AGENTS.md"); + } + + #[test] + fn test_upsert_end_before_begin() { + // An END marker that precedes a BEGIN marker forms no valid ordered pair. + // find_managed_markers returns None (BEGIN found, but no END after it), + // so the orphan BEGIN line is stripped and a new block is appended. + // The stray END line and content between END and BEGIN remain in the file + // because strip_orphan_begin_marker only removes the BEGIN line itself. + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!(result.contains("new section"), "new content must be present"); + assert!(result.contains("some middle content"), "content between markers must survive"); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); + } + + #[test] + fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!(result.contains("some content"), "original body must survive"); + assert!(result.contains("fresh section"), "new content must be present"); + + let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); + assert!( + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + } + + #[test] + fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("replaced"), "replacement content must be present"); + assert!(!result.contains("first block"), "first block must be replaced"); + assert!(result.contains("second block"), "second pair content must survive"); + assert!(result.contains("between blocks"), "text between pairs must survive"); + } + + #[test] + fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!(result.contains("appended content"), "new content must be appended"); + assert!(result.contains("Real content here"), "existing body must survive"); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" ); } } From 6b5dad2a30b94bd4105fe2ce2fd2936dc4751ee0 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 16:24:20 -0400 Subject: [PATCH 07/17] style: apply rustfmt to review-fix commit --- desktop/src-tauri/src/managed_agents/nest.rs | 78 ++++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index fe6497a37..b6d747173 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -292,7 +292,9 @@ fn find_marker_at_line_start(content: &str, marker: &str) -> Option { fn find_managed_markers(content: &str) -> Option<(usize, usize)> { let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?; let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - let end_pos = content[begin_pos..].find(END_MARKER).map(|p| p + begin_pos)?; + let end_pos = content[begin_pos..] + .find(END_MARKER) + .map(|p| p + begin_pos)?; let end_of_end = end_pos + END_MARKER.len(); let after_end = if content[end_of_end..].starts_with('\n') { end_of_end + 1 @@ -306,7 +308,10 @@ fn find_managed_markers(content: &str) -> Option<(usize, usize)> { fn strip_orphan_begin_marker(content: &str) -> String { if let Some(pos) = find_marker_at_line_start(content, BEGIN_MARKER) { let line_start = content[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - let line_end = content[pos..].find('\n').map(|p| pos + p + 1).unwrap_or(content.len()); + let line_end = content[pos..] + .find('\n') + .map(|p| pos + p + 1) + .unwrap_or(content.len()); format!( "{}{}", &content[..line_start], @@ -340,7 +345,10 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io }; let parent = file_path.parent().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "file path has no parent directory") + io::Error::new( + io::ErrorKind::InvalidInput, + "file path has no parent directory", + ) })?; let mut tmp = tempfile::NamedTempFile::new_in(parent)?; { @@ -691,7 +699,11 @@ mod tests { .unwrap() .filter_map(|e| e.ok()) .collect(); - assert_eq!(entries.len(), 1, "only AGENTS.md should remain, no temp files"); + assert_eq!( + entries.len(), + 1, + "only AGENTS.md should remain, no temp files" + ); assert_eq!(entries[0].file_name(), "AGENTS.md"); } @@ -715,8 +727,14 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); assert!(result.contains("# Header"), "original header must survive"); - assert!(result.contains("new section"), "new content must be present"); - assert!(result.contains("some middle content"), "content between markers must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). assert_eq!( @@ -726,7 +744,9 @@ mod tests { ); // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); assert!( end_pos.is_some(), @@ -754,10 +774,18 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); assert!(result.contains("# Header"), "original header must survive"); - assert!(result.contains("some content"), "original body must survive"); - assert!(result.contains("fresh section"), "new content must be present"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); - let begin_pos = result.find(BEGIN_MARKER).expect("BEGIN marker must be present"); + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); let end_pos = result.find(END_MARKER).expect("END marker must be present"); assert!( begin_pos < end_pos, @@ -786,10 +814,22 @@ mod tests { let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("replaced"), "replacement content must be present"); - assert!(!result.contains("first block"), "first block must be replaced"); - assert!(result.contains("second block"), "second pair content must survive"); - assert!(result.contains("between blocks"), "text between pairs must survive"); + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); } #[test] @@ -811,8 +851,14 @@ mod tests { result.contains(" "), "indented marker inside code block must be preserved verbatim" ); - assert!(result.contains("appended content"), "new content must be appended"); - assert!(result.contains("Real content here"), "existing body must survive"); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); // The real markers appended at the end must be at line-start (column 0). let begin_pos = result From 5a8e5438c9fc2daa3f617a093f16eb653c999c43 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 15 May 2026 15:51:02 -0400 Subject: [PATCH 08/17] fix(desktop): add nest.rs to file size check overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nest.rs grew to 770 lines with regenerate_nest_context, marker helpers, and 19 unit tests. The 500-line default is too tight for this file — override to 800 following the established pattern. --- desktop/scripts/check-file-sizes.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d93c83bf6..a9966c4f4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,6 +52,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + ["src-tauri/src/managed_agents/nest.rs", 800], // regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation From 9d3967a665663fa940698a1307d57242bf13f5c7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 15 May 2026 17:13:48 -0400 Subject: [PATCH 09/17] refactor: consolidate PR #583 into #584, remove CLI_QUICK_REFERENCE duplication base_prompt.md (injected every turn via [Base]) already has a comprehensive CLI reference table covering all 11 command groups. The CLI_QUICK_REFERENCE constant in nest.rs was a less complete duplicate (4 commands). AGENTS.md's dynamic section now focuses on what's unique to it: active agents and workspace info. Also bumps personas.rs file size override from 900 to 950 to accommodate the merge_personas inequality checks added in the review-fix commit. --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index a9966c4f4..d983bba65 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + ["src-tauri/src/managed_agents/personas.rs", 950], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index b6d747173..ca8ffa5cb 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -233,13 +233,6 @@ pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { Ok(()) } -const CLI_QUICK_REFERENCE: &str = "\ -## CLI Quick Reference -`sprout messages send --channel --content ` — send a message -`sprout messages get --channel ` — read recent messages -`sprout channels list` — list available channels -`sprout workflows trigger --workflow ` — trigger a workflow -Run `sprout --help` for the full command reference."; fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") @@ -271,7 +264,7 @@ pub fn render_dynamic_section( table }; - format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}\n\n{CLI_QUICK_REFERENCE}") + format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}") } /// Find a marker that appears at the start of a line (position 0 or preceded by `\n`). @@ -625,7 +618,7 @@ mod tests { let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); assert!(output.contains("| Kit | Builder | @Kit |")); assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## CLI Quick Reference")); + assert!(output.contains("## Workspace")); } #[test] From 8ef41e9b48636cd0bf4285ff6290dcc4ab67a8bc Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 15 May 2026 18:10:22 -0400 Subject: [PATCH 10/17] fix: address code review findings from crossfire review Review surfaced 10 findings across 5 independent sources (3 Claude specialists + Codex + Gemini). Key changes: - Fix incorrect CLI syntax in base_prompt.md (missing --channel flag, positional arg that should be named) and stale nest_agents.md reference - Apply line-start validation to END_MARKER in find_managed_markers, matching the BEGIN_MARKER contract documented in the function comment - Extract prepend_base_prompt helper to deduplicate [Base] injection across format_prompt, dispatch_heartbeat, and initial_message paths - Move base_prompt_file read from main.rs panic to Config::from_cli with proper ConfigError propagation and 1 MB size guard - Extract try_regenerate_nest helper to consolidate 11 identical error-handling blocks across command files - Sanitize relay_url before AGENTS.md injection (strip CR/LF) - Fix greedy newline strip in strip_orphan_begin_marker - Add doc comments for PromptContext.base_prompt lifetime constraint --- crates/sprout-acp/src/base_prompt.md | 6 ++--- crates/sprout-acp/src/config.rs | 26 ++++++++++++++++--- crates/sprout-acp/src/lib.rs | 14 +++++----- crates/sprout-acp/src/pool.rs | 12 ++++++--- crates/sprout-acp/src/queue.rs | 9 +++++++ .../src-tauri/src/commands/agent_models.rs | 10 +++---- desktop/src-tauri/src/commands/agents.rs | 16 +++++------- desktop/src-tauri/src/commands/personas.rs | 26 +++++-------------- desktop/src-tauri/src/commands/workspace.rs | 8 ++---- desktop/src-tauri/src/lib.rs | 8 +++--- desktop/src-tauri/src/managed_agents/nest.rs | 22 +++++++++++++--- 11 files changed, 89 insertions(+), 68 deletions(-) diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md index 5f82e213e..0bd8e6013 100644 --- a/crates/sprout-acp/src/base_prompt.md +++ b/crates/sprout-acp/src/base_prompt.md @@ -26,12 +26,12 @@ MCP tools (via `sprout-mcp`) are also available but the CLI is preferred for bat - Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery). - Use `sprout messages thread` or MCP `get_thread()` when responding in-thread; post new messages for new topics. -- No push notifications — poll with `sprout messages get --since=` or MCP `get_messages(since=)`. When `since` is set without `before`, results are oldest-first (chronological). +- No push notifications — poll with `sprout messages get --channel --since ` or MCP `get_messages(channel_id, since=)`. When `since` is set without `before`, results are oldest-first (chronological). ## Startup Recovery 1. `sprout feed get` (or MCP `get_feed()`) — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. -2. `sprout messages get ` on assigned channels — catch up on recent history. +2. `sprout messages get --channel ` on assigned channels — catch up on recent history. 3. Check `AGENTS.md` in your working directory for team context. 4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `sprout messages search --query "..."` for cross-channel keyword lookups. @@ -49,4 +49,4 @@ Your persistent workspace is in your working directory: | `REPOS/` | Checked-out source repositories | | `.scratch/` | Ephemeral working files | -Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `nest_agents.md` in your working directory for full workspace conventions. +Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions. diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 6bbff8fa9..2a5053a05 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -477,8 +477,10 @@ pub struct Config { pub agent_owner: Option, /// Disable the [Base] platform-context section prepended to every prompt. pub no_base_prompt: bool, - /// Path to a custom base prompt file that overrides the compiled-in default. - pub base_prompt_file: Option, + /// Resolved content from `--base-prompt-file`, read and validated in + /// `from_cli()`. `None` when using the compiled-in default or when + /// `--no-base-prompt` is set. + pub base_prompt_content: Option, } /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. @@ -608,6 +610,22 @@ impl Config { None }; + let base_prompt_content = if args.no_base_prompt { + None + } else if let Some(ref path) = args.base_prompt_file { + let content = std::fs::read_to_string(path)?; + if content.len() > 1_048_576 { + return Err(ConfigError::ConfigFile(format!( + "base prompt file {} exceeds 1 MB limit ({} bytes)", + path.display(), + content.len() + ))); + } + Some(content) + } else { + None + }; + if matches!(args.subscribe, SubscribeMode::Config) { if args.kinds.is_some() { tracing::warn!("--kinds is ignored in config mode"); @@ -817,7 +835,7 @@ impl Config { relay_observer: args.relay_observer, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, - base_prompt_file: args.base_prompt_file, + base_prompt_content, }; Ok(config) @@ -1182,7 +1200,7 @@ mod tests { relay_observer: false, agent_owner: None, no_base_prompt: false, - base_prompt_file: None, + base_prompt_content: None, } } diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index a57853837..a82d51f39 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -24,7 +24,7 @@ use pool::{ AgentPool, CancelMode, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState, }; -use queue::{EventQueue, QueuedEvent, ThreadTags}; +use queue::{prepend_base_prompt, EventQueue, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; use sprout_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, @@ -794,7 +794,7 @@ async fn tokio_main() -> Result<()> { .compact() .init(); - let config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; tracing::info!("sprout-acp starting: {}", config.summary()); let observer = config @@ -1069,6 +1069,7 @@ async fn tokio_main() -> Result<()> { let dedup_mode = config.dedup_mode; let mut queue = EventQueue::new(dedup_mode); + let base_prompt_content = config.base_prompt_content.take(); let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), @@ -1078,10 +1079,7 @@ async fn tokio_main() -> Result<()> { system_prompt: config.system_prompt.clone(), base_prompt: if config.no_base_prompt { None - } else if let Some(ref path) = config.base_prompt_file { - let content = std::fs::read_to_string(path).unwrap_or_else(|e| { - panic!("failed to read base prompt file {}: {e}", path.display()) - }); + } else if let Some(content) = base_prompt_content { Some(Box::leak(content.into_boxed_str())) } else { Some(include_str!("base_prompt.md")) @@ -2325,7 +2323,7 @@ fn dispatch_heartbeat( .clone() .unwrap_or_else(default_heartbeat_prompt); let prompt_text = match ctx.base_prompt { - Some(bp) => format!("[Base]\n{}\n\n{prompt_text}", bp.trim_end()), + Some(bp) => prepend_base_prompt(bp, &prompt_text), None => prompt_text, }; let result_tx = pool.result_tx(); @@ -2823,7 +2821,7 @@ mod build_mcp_servers_tests { relay_observer: false, agent_owner: None, no_base_prompt: false, - base_prompt_file: None, + base_prompt_content: None, } } diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index 6f7c61533..b55eb481e 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -35,8 +35,8 @@ use crate::acp::{ use crate::config::{DedupMode, PermissionMode}; use crate::observer; use crate::queue::{ - ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, - PromptProfileLookup, + prepend_base_prompt, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, + PromptProfile, PromptProfileLookup, }; use crate::relay::{ChannelInfo, RestClient}; @@ -192,6 +192,12 @@ pub struct PromptContext { pub dedup_mode: DedupMode, pub system_prompt: Option, pub heartbeat_prompt: Option, + /// Base prompt content, or `None` if `--no-base-prompt` was passed. + /// + /// `'static` because `PromptContext` is `Arc`-shared across async tasks. + /// Content from `--base-prompt-file` is promoted via `Box::leak` in `main.rs` + /// after validated file read in `Config::from_cli()`. The compiled-in default + /// (`include_str!`) is inherently `'static`. pub base_prompt: Option<&'static str>, pub cwd: String, /// REST client for pre-prompt context fetches (thread/DM history). @@ -827,7 +833,7 @@ pub async fn run_prompt_task( ); // Prepend base prompt to initial_message for platform orientation. let init_msg = match ctx.base_prompt { - Some(bp) => format!("[Base]\n{}\n\n{initial_msg}", bp.trim_end()), + Some(bp) => prepend_base_prompt(bp, initial_msg), None => initial_msg.to_string(), }; let init_result = agent diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index 0caec667a..cbeda85a7 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -953,6 +953,15 @@ pub struct FormatPromptArgs<'a> { pub profile_lookup: Option<&'a PromptProfileLookup>, } +/// Prepend the `[Base]` platform-context section to a prompt body. +/// +/// Used by the heartbeat and initial-message paths so the `[Base]` format +/// is defined in exactly one place. (`format_prompt` uses a sections-vec +/// approach instead, but the resulting `[Base]\n{content}` format is identical.) +pub fn prepend_base_prompt(base: &str, body: &str) -> String { + format!("[Base]\n{}\n\n{body}", base.trim_end()) +} + /// Format a [`FlushBatch`] into a prompt string for the agent. /// /// Produces a stable prompt with these sections (in order): diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 50986b4d4..fa8a41281 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -8,9 +8,9 @@ use crate::{ managed_agents::{ build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, load_managed_agents, managed_agent_avatar_url, missing_command_message, - normalize_agent_args, regenerate_nest_context, resolve_command, save_managed_agents, - sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, - UpdateManagedAgentRequest, UpdateManagedAgentResponse, + normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, + try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, + UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -247,9 +247,7 @@ pub async fn update_managed_agent( (summary, sync_params) }; // lock dropped here - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); // Phase 2: relay profile sync (async, best-effort, outside lock) let profile_sync_error = diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c2f89ae1e..a1d582456 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -7,10 +7,10 @@ use crate::{ build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, - normalize_agent_args, provider_deploy, read_log_tail, regenerate_nest_context, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, - BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, + normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, @@ -453,9 +453,7 @@ pub async fn create_managed_agent( (agent, spawn_error) }; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── let avatar_url = input @@ -721,9 +719,7 @@ pub fn delete_managed_agent( } save_managed_agents(&app, &records)?; } - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(()) } diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 39db8a987..b3d0d4504 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -7,7 +7,7 @@ use crate::{ managed_agents::{ encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents, load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona, - parse_zip_personas, regenerate_nest_context, save_managed_agents, save_personas, + parse_zip_personas, save_managed_agents, save_personas, try_regenerate_nest, uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest, @@ -85,9 +85,7 @@ pub fn create_persona( }; personas.push(persona.clone()); save_personas(&app, &personas)?; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(persona) } @@ -138,9 +136,7 @@ pub fn update_persona( .into_iter() .find(|record| record.id == input.id) .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(result) } @@ -186,9 +182,7 @@ pub fn delete_persona( if changed_agents { save_managed_agents(&app, &agents)?; } - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(()) } @@ -237,9 +231,7 @@ pub fn set_persona_active( let updated = persona.clone(); save_personas(&app, &personas)?; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(updated) } @@ -394,9 +386,7 @@ pub fn install_persona_pack( return Err(format!("pack path is not a directory: {path}")); } let result = import_persona_pack(&app, &source)?; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(result) } @@ -411,9 +401,7 @@ pub fn uninstall_persona_pack( .lock() .map_err(|e| e.to_string())?; do_uninstall_persona_pack(&app, &pack_id)?; - if let Err(error) = regenerate_nest_context(&app) { - eprintln!("sprout-desktop: nest context regeneration failed: {error}"); - } + try_regenerate_nest(&app); Ok(()) } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 615dd2324..79ca43f97 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -3,7 +3,7 @@ use serde::Serialize; use tauri::{AppHandle, State}; use crate::app_state::AppState; -use crate::managed_agents::regenerate_nest_context; +use crate::managed_agents::try_regenerate_nest; use crate::relay; #[derive(Serialize)] @@ -53,11 +53,7 @@ pub fn apply_workspace( *keys_guard = keys; } - if let Err(error) = regenerate_nest_context(&app) { - eprintln!( - "sprout-desktop: failed to regenerate nest context after workspace switch: {error}" - ); - } + try_regenerate_nest(&app); Ok(()) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2591f0dc5..8931ed4a4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -24,9 +24,9 @@ use huddle::{ speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - ensure_nest, kill_stale_tracked_processes, load_managed_agents, regenerate_nest_context, + ensure_nest, kill_stale_tracked_processes, load_managed_agents, restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes, - BackendKind, ManagedAgentProcess, + try_regenerate_nest, BackendKind, ManagedAgentProcess, }; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -403,9 +403,7 @@ pub fn run() { } } - if let Err(error) = regenerate_nest_context(&app_handle) { - eprintln!("sprout-desktop: failed to regenerate nest context: {error}"); - } + try_regenerate_nest(&app_handle); // Pre-download voice models in the background so they're ready diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index ca8ffa5cb..e7986715f 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -264,6 +264,7 @@ pub fn render_dynamic_section( table }; + let relay_url = relay_url.replace(['\n', '\r'], ""); format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}") } @@ -285,9 +286,8 @@ fn find_marker_at_line_start(content: &str, marker: &str) -> Option { fn find_managed_markers(content: &str) -> Option<(usize, usize)> { let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?; let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0); - let end_pos = content[begin_pos..] - .find(END_MARKER) - .map(|p| p + begin_pos)?; + let end_pos = + find_marker_at_line_start(&content[begin_pos..], END_MARKER).map(|p| p + begin_pos)?; let end_of_end = end_pos + END_MARKER.len(); let after_end = if content[end_of_end..].starts_with('\n') { end_of_end + 1 @@ -308,7 +308,9 @@ fn strip_orphan_begin_marker(content: &str) -> String { format!( "{}{}", &content[..line_start], - content[line_end..].trim_start_matches('\n') + content[line_end..] + .strip_prefix('\n') + .unwrap_or(&content[line_end..]) ) } else { content.to_string() @@ -372,6 +374,16 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { Ok(()) } +/// Convenience wrapper: regenerates nest context, logging a warning on failure. +/// +/// All call sites treat regeneration as fire-and-forget — agents run fine with +/// a stale AGENTS.md, so we warn and continue rather than propagating the error. +pub fn try_regenerate_nest(app: &AppHandle) { + if let Err(error) = regenerate_nest_context(app) { + eprintln!("sprout-desktop: nest context regeneration failed: {error}"); + } +} + #[cfg(test)] mod tests { @@ -569,6 +581,7 @@ mod tests { is_active: true, source_pack: None, source_pack_persona_slug: None, + env_vars: std::collections::BTreeMap::new(), created_at: String::new(), updated_at: String::new(), } @@ -608,6 +621,7 @@ mod tests { last_error: None, respond_to: RespondTo::default(), respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), } } From ed6c20e2fc2219defe7e032e3f99db5639df5818 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 11:35:04 -0400 Subject: [PATCH 11/17] style: fix formatting and bump nest.rs file size override rustfmt fixes after rebase onto main (extra blank lines in lib.rs and nest.rs). Bump nest.rs file size override from 800 to 960 to account for skill file init code merged from #613. Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/lib.rs | 1 - desktop/src-tauri/src/managed_agents/nest.rs | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d983bba65..c9d236712 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,7 +52,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field - ["src-tauri/src/managed_agents/nest.rs", 800], // regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests + ["src-tauri/src/managed_agents/nest.rs", 960], // ensure_nest_at (AGENTS.md + SKILL.md + permissions) + ensure_cli_symlink + regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8931ed4a4..eb204ff54 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -405,7 +405,6 @@ pub fn run() { try_regenerate_nest(&app_handle); - // Pre-download voice models in the background so they're ready // when the user starts their first huddle. Idempotent — no-op if // already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS). diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e7986715f..f34c9d028 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -233,7 +233,6 @@ pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { Ok(()) } - fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } @@ -384,7 +383,6 @@ pub fn try_regenerate_nest(app: &AppHandle) { } } - #[cfg(test)] mod tests { use super::*; From f7e2d557eb3113b347f3ce8187a10d506e0d4996 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:50:47 -0400 Subject: [PATCH 12/17] refactor: replace MCP tool references with CLI commands in agent prompts Agent-facing prompts (base prompt, context hints, heartbeat, AGENTS.md template) still referenced MCP function-call syntax despite the CLI migration. Additionally, existing users never received template updates because nest files used write-once semantics. Strip all MCP references from the four agent-facing sources compiled into the binary. Add version-gated refresh to ensure_nest_at so AGENTS.md static content (above managed markers) and SKILL.md are updated on binary upgrades while preserving the dynamic agent table. # Conflicts: # desktop/scripts/check-file-sizes.mjs --- crates/sprout-acp/src/base_prompt.md | 8 +- crates/sprout-acp/src/lib.rs | 13 +- crates/sprout-acp/src/queue.rs | 64 +++--- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 210 +++++++++++++++++- .../src/managed_agents/nest_agents.md | 29 +-- 6 files changed, 252 insertions(+), 74 deletions(-) diff --git a/crates/sprout-acp/src/base_prompt.md b/crates/sprout-acp/src/base_prompt.md index 0bd8e6013..5bec46770 100644 --- a/crates/sprout-acp/src/base_prompt.md +++ b/crates/sprout-acp/src/base_prompt.md @@ -20,17 +20,15 @@ The `sprout` CLI is your primary interface. Auth env vars: `SPROUT_RELAY_URL`, ` Run `sprout --help` or `sprout --help` for full usage. -MCP tools (via `sprout-mcp`) are also available but the CLI is preferred for batch operations and scripting. - ## Communication Patterns - Address agents and humans with plain `@name` — do NOT bold or italicize mention text (formatting prevents alert delivery). -- Use `sprout messages thread` or MCP `get_thread()` when responding in-thread; post new messages for new topics. -- No push notifications — poll with `sprout messages get --channel --since ` or MCP `get_messages(channel_id, since=)`. When `since` is set without `before`, results are oldest-first (chronological). +- Use `sprout messages thread` when responding in-thread; post new messages for new topics. +- No push notifications — poll with `sprout messages get --channel --since `. When `since` is set without `before`, results are oldest-first (chronological). ## Startup Recovery -1. `sprout feed get` (or MCP `get_feed()`) — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. +1. `sprout feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. 2. `sprout messages get --channel ` on assigned channels — catch up on recent history. 3. Check `AGENTS.md` in your working directory for team context. 4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `sprout messages search --query "..."` for cross-channel keyword lookups. diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index a82d51f39..0b26ccd89 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -2356,14 +2356,15 @@ fn default_heartbeat_prompt() -> String { You have been awakened for a routine heartbeat. You have NO incoming messages or\n\ active channel context for this turn.\n\n\ Your tasks:\n\ - 1. Call `get_feed(types='needs_action')` to check for pending workflow approvals or\n\ + 1. Run `sprout feed get --types needs_action` to check for pending workflow approvals or\n\ high-priority requests addressed to you.\n\ - 2. Call `get_feed(types='mentions')` to check for unanswered @mentions.\n\ - 3. If you find actionable items, address them using the appropriate tools\n\ - (e.g., `approve_step`, `send_message`, `send_message(parent_event_id=...)`).\n\ + 2. Run `sprout feed get --types mentions` to check for unanswered @mentions.\n\ + 3. If you find actionable items, address them using the appropriate CLI commands\n\ + (e.g., `sprout workflows approve --token `, `sprout messages send`,\n\ + `sprout messages send --reply-to `).\n\ 4. If there are no pending actions or mentions, end your turn immediately.\n\n\ - Do not call `list_channels()` or `search()` unless you have a specific reason.\n\ - Do not invent work — only act on items surfaced by the feed tools." + Do not run `sprout channels list` or `sprout messages search` unless you have a specific reason.\n\ + Do not invent work — only act on items surfaced by the feed commands." ) } diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index cbeda85a7..97afa316c 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -816,14 +816,14 @@ fn format_event_block( /// Append a reply instruction when the agent is responding to a thread event. /// -/// Tells the agent to pass the triggering event's ID as `parent_event_id` on -/// every tool call in this turn, and to leave `broadcast_to_channel` unset so -/// replies stay inside the thread. +/// Tells the agent to pass `--reply-to ` on every `sprout messages +/// send` call in this turn, and not to broadcast to the channel so replies +/// stay inside the thread. fn append_reply_instruction(s: &mut String, event_id: &str) { s.push_str(&format!( - "\nIMPORTANT: When responding, pass parent_event_id=\"{event_id}\" \ - on EVERY send_message and send_diff_message call in this turn. \ - Do not set broadcast_to_channel." + "\nIMPORTANT: When responding, use `--reply-to {event_id}` \ + on EVERY `sprout messages send` call in this turn. \ + Do not broadcast to the channel." )); } @@ -845,16 +845,16 @@ fn format_context_hints( // and the scope should be "dm" (not "thread") because the agent is in a DM. if is_dm { let is_reply = thread_tags.root_event_id.is_some(); - // DM replies use get_thread() because /messages excludes thread replies. - // DM non-replies use get_messages() for recent conversation. + // DM replies use thread command because /messages excludes thread replies. + // DM non-replies use get for recent conversation. let ctx_hint = if has_conversation_context && is_reply { - "Thread context included below. Use get_thread() for full history if truncated." + "Thread context included below. Use `sprout messages thread --channel --event ` for full history if truncated." } else if has_conversation_context { - "Conversation context included below. Use get_messages() for full history if truncated." + "Conversation context included below. Use `sprout messages get --channel ` for full history if truncated." } else if is_reply { - "Use get_thread() to fetch the reply chain." + "Use `sprout messages thread --channel --event ` to fetch the reply chain." } else { - "Use get_messages() for conversation context." + "Use `sprout messages get --channel ` for conversation context." }; let mut s = format!( "[Context]\n\ @@ -877,9 +877,9 @@ fn format_context_hints( s } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { - "Thread context included below. Use get_thread() for full history if truncated." + "Thread context included below. Use `sprout messages thread --channel --event ` for full history if truncated." } else { - "Use get_thread() to fetch thread context." + "Use `sprout messages thread --channel --event ` to fetch thread context." }; let mut s = format!( "[Context]\n\ @@ -902,7 +902,7 @@ fn format_context_hints( "[Context]\n\ Scope: channel\n\ Channel: {channel_display}\n\ - Hint: Use get_messages() for recent messages if needed." + Hint: Use `sprout messages get --channel ` for recent messages if needed." ) } } @@ -2404,10 +2404,10 @@ mod tests { prompt.contains("Scope: dm"), "DM reply should have Scope: dm, got:\n{prompt}" ); - // Hint should point to get_thread(), not get_messages(). + // Hint should point to the thread command, not get. assert!( - prompt.contains("get_thread()"), - "DM reply hint should mention get_thread(), got:\n{prompt}" + prompt.contains("sprout messages thread"), + "DM reply hint should mention `sprout messages thread`, got:\n{prompt}" ); // Thread structural info should be present. assert!( @@ -2446,12 +2446,12 @@ mod tests { ); assert!(prompt.contains("Scope: dm")); assert!( - prompt.contains("get_messages()"), - "DM non-reply hint should mention get_messages()" + prompt.contains("sprout messages get"), + "DM non-reply hint should mention `sprout messages get`" ); assert!( - !prompt.contains("get_thread()"), - "DM non-reply should NOT mention get_thread()" + !prompt.contains("sprout messages thread"), + "DM non-reply should NOT mention `sprout messages thread`" ); } @@ -2841,11 +2841,11 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( - prompt.contains(&format!("parent_event_id=\"{event_id}\"")), + prompt.contains(&format!("--reply-to {event_id}")), "channel thread reply should include reply instruction with triggering event ID" ); assert!( - prompt.contains("Do not set broadcast_to_channel"), + prompt.contains("Do not broadcast to the channel"), "channel thread reply should include broadcast suppression hint" ); } @@ -2881,7 +2881,7 @@ mod tests { }, ); assert!( - prompt.contains(&format!("parent_event_id=\"{event_id}\"")), + prompt.contains(&format!("--reply-to {event_id}")), "DM thread reply should include reply instruction" ); } @@ -2902,7 +2902,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( - !prompt.contains("parent_event_id"), + !prompt.contains("--reply-to"), "top-level message should NOT include reply instruction" ); } @@ -2933,7 +2933,7 @@ mod tests { }, ); assert!( - !prompt.contains("parent_event_id"), + !prompt.contains("--reply-to"), "DM non-reply should NOT include reply instruction" ); } @@ -2964,15 +2964,15 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()); // The instruction should use the triggering event's own ID — not root or parent. assert!( - prompt.contains(&format!("parent_event_id=\"{event_id}\"")), + prompt.contains(&format!("--reply-to {event_id}")), "nested reply instruction should use the triggering event ID" ); assert!( - !prompt.contains(&format!("parent_event_id=\"{root_id}\"")), + !prompt.contains(&format!("--reply-to {root_id}")), "instruction should NOT use root_event_id" ); assert!( - !prompt.contains(&format!("parent_event_id=\"{parent_id}\"")), + !prompt.contains(&format!("--reply-to {parent_id}")), "instruction should NOT use parent_event_id from tags" ); } @@ -3006,7 +3006,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( - prompt.contains(&format!("parent_event_id=\"{threaded_id}\"")), + prompt.contains(&format!("--reply-to {threaded_id}")), "batched prompt should use last (threaded) event's ID" ); } @@ -3039,7 +3039,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()); assert!( - !prompt.contains("parent_event_id"), + !prompt.contains("--reply-to"), "batched prompt where last event is top-level should NOT include reply instruction" ); } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c9d236712..baa0031a9 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -52,7 +52,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field - ["src-tauri/src/managed_agents/nest.rs", 960], // ensure_nest_at (AGENTS.md + SKILL.md + permissions) + ensure_cli_symlink + regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + 19 unit tests + ["src-tauri/src/managed_agents/nest.rs", 1160], // ensure_nest_at (AGENTS.md + SKILL.md + permissions) + ensure_cli_symlink + regenerate_nest_context + render_dynamic_section + upsert_managed_section + marker helpers + version-gated refresh (refresh_agents_md_if_stale + refresh_skill_md_if_stale) + 25 unit tests ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index f34c9d028..b3255ec5d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -4,7 +4,8 @@ //! Sprout-spawned agent starts with orientation (AGENTS.md) and a //! place to accumulate research, plans, and logs across sessions. //! -//! Idempotent: existing files and directories are never overwritten. +//! Static template content in AGENTS.md (above the managed-section markers) +//! and SKILL.md is refreshed when the embedded template version changes. use super::{load_managed_agents, load_personas, ManagedAgentRecord, PersonaRecord}; #[cfg(test)] @@ -35,6 +36,15 @@ const AGENTS_MD: &str = include_str!("nest_agents.md"); /// Written to ~/.sprout/.claude/skills/sprout-cli/SKILL.md on first init. const SPROUT_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); +/// Template content version for AGENTS.md static content (above managed markers). +/// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. +/// Version 1 is implicitly "before this mechanism existed" (no version file). +const NEST_AGENTS_VERSION: u32 = 2; + +/// Template content version for SKILL.md. +/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. +const NEST_SKILL_VERSION: u32 = 2; + const BEGIN_MARKER: &str = ""; /// Returns the nest root path (`~/.sprout`), or `None` if the home @@ -60,8 +70,10 @@ pub fn ensure_nest() -> Result<(), String> { /// - Sets 700 permissions on the root, all subdirectories, and the skill /// directory tree (Unix). /// -/// Idempotent: safe to call on every launch. Existing files are never -/// overwritten — users can freely edit AGENTS.md or SKILL.md and they persist. +/// Idempotent: safe to call on every launch. Static template content in +/// AGENTS.md (above the managed-section markers) and SKILL.md is refreshed +/// when the embedded template version changes. The managed section in AGENTS.md +/// and any user content below it are preserved. /// /// Rejects symlinks at the root path to prevent redirect attacks. /// @@ -134,6 +146,10 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } + // Refresh static content if the embedded template version is newer. + refresh_agents_md_if_stale(root)?; + refresh_skill_md_if_stale(root)?; + // Set owner-only permissions on root and all subdirectories. // Skip any path that is a symlink — chmod would affect the target. #[cfg(unix)] @@ -233,6 +249,99 @@ pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { Ok(()) } +/// Read a version number from a file. Returns 0 if the file doesn't exist or can't be parsed. +fn read_version_file(path: &Path) -> u32 { + fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) +} + +/// Refresh AGENTS.md static content if the template version has changed. +/// +/// Preserves everything from the `"; @@ -66,7 +66,8 @@ pub fn ensure_nest() -> Result<(), String> { /// /// - Creates the root directory and all subdirectories. /// - Writes `AGENTS.md` only if it doesn't already exist. -/// - Writes `.claude/skills/sprout-cli/SKILL.md` only if it doesn't already exist. +/// - Writes `.agents/skills/sprout-cli/SKILL.md` only if it doesn't already exist. +/// - Creates a `.claude/skills/sprout-cli` symlink to the `.agents` location. /// - Sets 700 permissions on the root, all subdirectories, and the skill /// directory tree (Unix). /// @@ -125,11 +126,14 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } - // Write sprout-cli skill alongside AGENTS.md (same idempotent pattern). - let skill_dir = root.join(".claude/skills/sprout-cli"); - fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {e}", skill_dir.display()))?; + // Write sprout-cli skill to the harness-agnostic .agents path. + // The first-init write uses the new canonical path; migration from + // the old .claude path is handled in refresh_skill_md_if_stale. + let agents_skill_dir = root.join(".agents/skills/sprout-cli"); + fs::create_dir_all(&agents_skill_dir) + .map_err(|e| format!("create {}: {e}", agents_skill_dir.display()))?; - let skill_md = root.join(".claude/skills/sprout-cli/SKILL.md"); + let skill_md = agents_skill_dir.join("SKILL.md"); match fs::OpenOptions::new() .write(true) .create_new(true) @@ -146,6 +150,25 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } + // Create .claude/skills/sprout-cli symlink for Claude Code compatibility. + #[cfg(unix)] + { + let claude_skills_dir = root.join(".claude/skills"); + fs::create_dir_all(&claude_skills_dir) + .map_err(|e| format!("create {}: {e}", claude_skills_dir.display()))?; + let symlink_path = root.join(".claude/skills/sprout-cli"); + // Only create symlink if it doesn't already exist as a symlink. + // If it's a real directory (pre-migration), refresh_skill_md_if_stale handles migration. + let is_symlink = symlink_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !is_symlink && !symlink_path.exists() { + std::os::unix::fs::symlink("../../.agents/skills/sprout-cli", &symlink_path) + .map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?; + } + } + // Refresh static content if the embedded template version is newer. refresh_agents_md_if_stale(root)?; refresh_skill_md_if_stale(root)?; @@ -169,13 +192,15 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } - // Skill directory and its intermediate parents inside root get 700. - // create_dir_all creates .claude/ and .claude/skills/ with umask - // defaults — lock them down the same way we do NEST_DIRS. + // .agents skill dir tree and .claude parents inside root get 700. + // create_dir_all creates them with umask defaults — lock them down + // the same way we do NEST_DIRS. Skip symlinks (chmod affects target). for dir in [ + root.join(".agents"), + root.join(".agents/skills"), + agents_skill_dir.clone(), root.join(".claude"), root.join(".claude/skills"), - skill_dir.clone(), ] { let is_symlink = dir .symlink_metadata() @@ -318,24 +343,71 @@ fn refresh_agents_md_if_stale(root: &Path) -> Result<(), String> { /// /// SKILL.md has no user-editable sections — it is fully overwritten on version bump. fn refresh_skill_md_if_stale(root: &Path) -> Result<(), String> { - let version_path = root.join(".claude/skills/sprout-cli/.skill-version"); + let agents_skill_dir = root.join(".agents/skills/sprout-cli"); + let version_path = agents_skill_dir.join(".skill-version"); if read_version_file(&version_path) >= NEST_SKILL_VERSION { return Ok(()); } - let skill_md = root.join(".claude/skills/sprout-cli/SKILL.md"); + // Migration: if .claude/skills/sprout-cli exists as a real directory + // (pre-migration install), copy user's SKILL.md to the new location + // then remove the old directory so we can replace it with a symlink. + let old_skill_dir = root.join(".claude/skills/sprout-cli"); + let old_is_real_dir = old_skill_dir + .symlink_metadata() + .map(|m| m.file_type().is_dir()) + .unwrap_or(false); + + let skill_content = if old_is_real_dir { + // Preserve user-edited content during migration. + fs::read_to_string(old_skill_dir.join("SKILL.md")) + .unwrap_or_else(|_| SPROUT_CLI_SKILL_MD.to_string()) + } else { + SPROUT_CLI_SKILL_MD.to_string() + }; + + // Ensure the canonical .agents skill directory exists. + fs::create_dir_all(&agents_skill_dir) + .map_err(|e| format!("create {}: {e}", agents_skill_dir.display()))?; + // Atomic write via temp file. - let parent = skill_md.parent().ok_or("SKILL.md has no parent dir")?; - let mut tmp = tempfile::NamedTempFile::new_in(parent) - .map_err(|e| format!("tempfile in {}: {e}", parent.display()))?; + let skill_md = agents_skill_dir.join("SKILL.md"); + let mut tmp = tempfile::NamedTempFile::new_in(&agents_skill_dir) + .map_err(|e| format!("tempfile in {}: {e}", agents_skill_dir.display()))?; { use std::io::Write; - tmp.write_all(SPROUT_CLI_SKILL_MD.as_bytes()) + tmp.write_all(skill_content.as_bytes()) .map_err(|e| format!("write tempfile: {e}"))?; } tmp.persist(&skill_md) .map_err(|e| format!("persist {}: {e}", skill_md.display()))?; + // Replace old real directory with a symlink. + if old_is_real_dir { + fs::remove_dir_all(&old_skill_dir) + .map_err(|e| format!("remove {}: {e}", old_skill_dir.display()))?; + } + + // Create/replace the .claude/skills/sprout-cli symlink. + #[cfg(unix)] + { + let claude_skills_dir = root.join(".claude/skills"); + fs::create_dir_all(&claude_skills_dir) + .map_err(|e| format!("create {}: {e}", claude_skills_dir.display()))?; + let symlink_path = root.join(".claude/skills/sprout-cli"); + // Remove any stale symlink before (re)creating. + let symlink_exists = symlink_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if symlink_exists { + fs::remove_file(&symlink_path) + .map_err(|e| format!("remove symlink {}: {e}", symlink_path.display()))?; + } + std::os::unix::fs::symlink("../../.agents/skills/sprout-cli", &symlink_path) + .map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?; + } + fs::write(&version_path, format!("{NEST_SKILL_VERSION}\n")) .map_err(|e| format!("write {}: {e}", version_path.display()))?; @@ -582,10 +654,22 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - let skill = root.join(".claude/skills/sprout-cli/SKILL.md"); - assert!(skill.exists(), "SKILL.md should exist"); + + // Canonical location under .agents. + let skill = root.join(".agents/skills/sprout-cli/SKILL.md"); + assert!(skill.exists(), "SKILL.md should exist at .agents path"); let content = fs::read_to_string(&skill).unwrap(); assert_eq!(content, SPROUT_CLI_SKILL_MD); + + // .claude/skills/sprout-cli is a symlink on Unix. + #[cfg(unix)] + { + let symlink = root.join(".claude/skills/sprout-cli"); + assert!( + symlink.symlink_metadata().unwrap().file_type().is_symlink(), + ".claude/skills/sprout-cli should be a symlink" + ); + } } #[test] @@ -593,8 +677,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - let skill = root.join(".claude/skills/sprout-cli/SKILL.md"); + + let skill = root.join(".agents/skills/sprout-cli/SKILL.md"); fs::write(&skill, "custom skill content").unwrap(); + ensure_nest_at(&root).unwrap(); assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content"); } @@ -606,8 +692,15 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - // All three dirs in the skill path should be locked down. - for dir in [".claude", ".claude/skills", ".claude/skills/sprout-cli"] { + // Real dirs in .agents tree and .claude parents should be 700. + // .claude/skills/sprout-cli is a symlink — excluded from chmod check. + for dir in [ + ".agents", + ".agents/skills", + ".agents/skills/sprout-cli", + ".claude", + ".claude/skills", + ] { let path = root.join(dir); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "{dir} should be 700"); @@ -643,6 +736,46 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ensure_nest_migrates_old_skill_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + + // Simulate a pre-migration install: real directory at old path. + // Create the nest first to get all dirs, then simulate old layout. + ensure_nest_at(&root).unwrap(); + + // Remove the symlink and new skill dir, recreate old real dir. + let _ = fs::remove_file(root.join(".claude/skills/sprout-cli")); + let _ = fs::remove_dir_all(root.join(".agents/skills/sprout-cli")); + let old_skill_dir = root.join(".claude/skills/sprout-cli"); + fs::create_dir_all(&old_skill_dir).unwrap(); + fs::write(old_skill_dir.join("SKILL.md"), "user edited skill").unwrap(); + + // Delete version file to force refresh. + let _ = fs::remove_file(root.join(".agents/skills/sprout-cli/.skill-version")); + + // Re-run ensure_nest_at — should trigger migration in refresh_skill_md_if_stale. + ensure_nest_at(&root).unwrap(); + + // New canonical location exists with user's content preserved. + let new_skill = root.join(".agents/skills/sprout-cli/SKILL.md"); + assert!(new_skill.exists(), "SKILL.md should exist at new path"); + assert_eq!(fs::read_to_string(&new_skill).unwrap(), "user edited skill"); + + // Old path is now a symlink, not a real directory. + let old_path = root.join(".claude/skills/sprout-cli"); + assert!( + old_path + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "old path should now be a symlink" + ); + } + #[cfg(unix)] #[test] fn ensure_cli_symlink_creates_symlink() { @@ -1077,7 +1210,7 @@ mod tests { let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); let version = - fs::read_to_string(root.join(".claude/skills/sprout-cli/.skill-version")).unwrap(); + fs::read_to_string(root.join(".agents/skills/sprout-cli/.skill-version")).unwrap(); assert_eq!(version.trim(), NEST_SKILL_VERSION.to_string()); } @@ -1142,11 +1275,11 @@ mod tests { let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - let skill_md = root.join(".claude/skills/sprout-cli/SKILL.md"); + let skill_md = root.join(".agents/skills/sprout-cli/SKILL.md"); fs::write(&skill_md, "stale skill content").unwrap(); // Remove version file to simulate upgrade. - let _ = fs::remove_file(root.join(".claude/skills/sprout-cli/.skill-version")); + let _ = fs::remove_file(root.join(".agents/skills/sprout-cli/.skill-version")); ensure_nest_at(&root).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 2dc3f4f09..0a8f1ef02 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -44,7 +44,6 @@ created: 2026-01-15 - **`.scratch/` is disposable** — don't rely on it across sessions - **Never push without approval** — do not `git push` to any remote - **Stay on task** — only stage files relevant to your current work -- **Tagging or @mentioning others** — you can mention other bots or users by simply @'ing them in your message, but you cannot bold, italicize, or otherwise format the mention text if you want them to actually be alerted ## Active Agents diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 770618fdb..2cf295154 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -529,8 +529,15 @@ pub fn spawn_agent_child( let agent_args = normalize_agent_args(&record.agent_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command, Some(app)) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let resolved_mcp_command = resolve_command(&record.mcp_command, Some(app)) - .ok_or_else(|| missing_command_message(&record.mcp_command, "MCP server command"))?; + let resolved_mcp_command: Option = if record.mcp_command.is_empty() { + None + } else { + Some( + resolve_command(&record.mcp_command, Some(app)).ok_or_else(|| { + missing_command_message(&record.mcp_command, "MCP server command") + })?, + ) + }; // Resolve agent command to a full path (DMG launches have minimal PATH). let resolved_agent_command = resolve_command(&record.agent_command, Some(app)) .map(|p| p.display().to_string()) @@ -575,7 +582,14 @@ pub fn spawn_agent_child( command.env("SPROUT_RELAY_URL", &record.relay_url); command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); - command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command); + match &resolved_mcp_command { + Some(mcp_cmd) => { + command.env("SPROUT_ACP_MCP_COMMAND", mcp_cmd); + } + None => { + command.env("SPROUT_ACP_MCP_COMMAND", ""); + } + } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp". if known_acp_provider(&record.agent_command).is_some_and(|p| p.mcp_hooks) { From 517e846672dcc36e9537db2447316ba9b59d104f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:52:26 -0400 Subject: [PATCH 15/17] refactor(desktop): generalize skill symlinks via KnownAcpProvider Only Claude Code got a symlink for skill discovery; Goose, Codex, and future harnesses were ignored. Add a skill_dir field to KnownAcpProvider so each harness declares its own skill path, then loop over all providers to create relative symlinks from each harness dir to the canonical .agents/skills/sprout-cli. Moves the real SKILL.md to .agents/skills/sprout-cli (agent-neutral canonical path) with symlinks at .goose/skills/, .claude/skills/, and .codex/skills/. # Conflicts: # desktop/src-tauri/src/managed_agents/discovery.rs --- .../src-tauri/src/managed_agents/discovery.rs | 14 ++ desktop/src-tauri/src/managed_agents/nest.rs | 189 ++++++++++++++---- 2 files changed, 165 insertions(+), 38 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index e2483a2c8..d58fdef2f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -31,6 +31,11 @@ pub(crate) struct KnownAcpProvider { pub cli_install_hint: &'static str, /// Human-readable hint about installing the ACP adapter. pub adapter_install_hint: &'static str, + /// Harness-specific skill discovery directory (e.g. `.goose/skills`). + /// `Some(dir)` → Sprout creates a symlink at `//sprout-cli` + /// pointing to the canonical `.agents/skills/sprout-cli`. `None` → this + /// provider reads the canonical path directly or has no skill support. + pub skill_dir: Option<&'static str>, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -76,6 +81,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ install_instructions_url: "https://block.github.io/goose/", cli_install_hint: "Install Goose via the official install script.", adapter_install_hint: "", + skill_dir: Some(".goose/skills"), }, KnownAcpProvider { id: "claude", @@ -91,6 +97,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", cli_install_hint: "Install the Claude Code CLI via the official install script.", adapter_install_hint: "Install the Claude Code ACP adapter via npm.", + skill_dir: Some(".claude/skills"), }, KnownAcpProvider { id: "codex", @@ -106,6 +113,7 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ install_instructions_url: "https://github.com/zed-industries/codex-acp", cli_install_hint: "Install the Codex CLI via the official install script.", adapter_install_hint: "Install the Codex ACP adapter via npm.", + skill_dir: Some(".codex/skills"), }, KnownAcpProvider { id: "sprout-agent", @@ -121,9 +129,15 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ install_instructions_url: "https://github.com/block/sprout", cli_install_hint: "Ships with the Sprout desktop app.", adapter_install_hint: "", + skill_dir: None, }, ]; +/// Skill discovery directories declared by known providers. +pub(crate) fn known_skill_dirs() -> impl Iterator { + KNOWN_ACP_PROVIDERS.iter().filter_map(|p| p.skill_dir) +} + fn workspace_root_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index eee32d94f..00c5dc355 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -17,6 +17,8 @@ use std::io; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; +use crate::managed_agents::discovery::known_skill_dirs; + /// Subdirectories created inside the nest. const NEST_DIRS: &[&str] = &[ "GUIDES", @@ -47,6 +49,9 @@ const NEST_SKILL_VERSION: u32 = 3; const BEGIN_MARKER: &str = ""; + +/// Canonical skill directory path relative to the nest root. +const CANONICAL_SKILL_DIR: &str = ".agents/skills/sprout-cli"; /// Returns the nest root path (`~/.sprout`), or `None` if the home /// directory cannot be resolved. pub fn nest_dir() -> Option { @@ -67,7 +72,8 @@ pub fn ensure_nest() -> Result<(), String> { /// - Creates the root directory and all subdirectories. /// - Writes `AGENTS.md` only if it doesn't already exist. /// - Writes `.agents/skills/sprout-cli/SKILL.md` only if it doesn't already exist. -/// - Creates a `.claude/skills/sprout-cli` symlink to the `.agents` location. +/// - Creates harness-specific symlinks (e.g. `.claude/skills/sprout-cli`) pointing +/// to the canonical `.agents/skills/sprout-cli` directory. /// - Sets 700 permissions on the root, all subdirectories, and the skill /// directory tree (Unix). /// @@ -129,7 +135,7 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { // Write sprout-cli skill to the harness-agnostic .agents path. // The first-init write uses the new canonical path; migration from // the old .claude path is handled in refresh_skill_md_if_stale. - let agents_skill_dir = root.join(".agents/skills/sprout-cli"); + let agents_skill_dir = root.join(CANONICAL_SKILL_DIR); fs::create_dir_all(&agents_skill_dir) .map_err(|e| format!("create {}: {e}", agents_skill_dir.display()))?; @@ -150,29 +156,16 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } - // Create .claude/skills/sprout-cli symlink for Claude Code compatibility. - #[cfg(unix)] - { - let claude_skills_dir = root.join(".claude/skills"); - fs::create_dir_all(&claude_skills_dir) - .map_err(|e| format!("create {}: {e}", claude_skills_dir.display()))?; - let symlink_path = root.join(".claude/skills/sprout-cli"); - // Only create symlink if it doesn't already exist as a symlink. - // If it's a real directory (pre-migration), refresh_skill_md_if_stale handles migration. - let is_symlink = symlink_path - .symlink_metadata() - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - if !is_symlink && !symlink_path.exists() { - std::os::unix::fs::symlink("../../.agents/skills/sprout-cli", &symlink_path) - .map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?; - } - } + // Create harness-specific symlinks for all known providers. + // Migration of the old .claude/skills/sprout-cli real dir is handled in + // refresh_skill_md_if_stale; ensure_skill_symlinks skips paths that already exist. + ensure_skill_symlinks(root)?; // Refresh static content if the embedded template version is newer. refresh_agents_md_if_stale(root)?; refresh_skill_md_if_stale(root)?; + // Set owner-only permissions on root and all subdirectories. // Skip any path that is a symlink — chmod would affect the target. #[cfg(unix)] @@ -192,16 +185,25 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } - // .agents skill dir tree and .claude parents inside root get 700. - // create_dir_all creates them with umask defaults — lock them down - // the same way we do NEST_DIRS. Skip symlinks (chmod affects target). - for dir in [ - root.join(".agents"), - root.join(".agents/skills"), - agents_skill_dir.clone(), - root.join(".claude"), - root.join(".claude/skills"), - ] { + // Skill directory trees inside root get 700. + // Build the list from canonical path + all known provider skill dirs. + let mut skill_perm_dirs = Vec::new(); + { + let mut accumulated = std::path::PathBuf::new(); + for component in std::path::Path::new(CANONICAL_SKILL_DIR).components() { + accumulated.push(component); + skill_perm_dirs.push(root.join(&accumulated)); + } + } + for skill_dir in known_skill_dirs() { + // Ensure every ancestor dir gets 700, not just the leaf. + let mut accumulated = std::path::PathBuf::new(); + for component in std::path::Path::new(skill_dir).components() { + accumulated.push(component); + skill_perm_dirs.push(root.join(&accumulated)); + } + } + for dir in skill_perm_dirs { let is_symlink = dir .symlink_metadata() .map(|m| m.file_type().is_symlink()) @@ -216,6 +218,32 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { Ok(()) } +/// Create harness-specific skill symlinks for each known provider. +/// Idempotent: skips any path where `symlink_metadata` succeeds — real +/// directories, valid symlinks, and dangling symlinks are all left alone. +#[cfg(unix)] +fn ensure_skill_symlinks(root: &Path) -> Result<(), String> { + for skill_dir in known_skill_dirs() { + let parent = root.join(skill_dir); + fs::create_dir_all(&parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + let link = parent.join("sprout-cli"); + if link.symlink_metadata().is_ok() { + continue; // symlink or real path exists — skip + } + let depth = std::path::Path::new(skill_dir).components().count(); + let prefix = "../".repeat(depth); + let target = format!("{prefix}{CANONICAL_SKILL_DIR}"); + std::os::unix::fs::symlink(&target, &link) + .map_err(|e| format!("symlink {} → {}: {e}", link.display(), target))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> { + Ok(()) +} + /// Ensures `~/.local/bin/sprout` is a symlink to the bundled CLI binary. /// /// Creates the symlink if it doesn't exist, updates it if it already points @@ -661,14 +689,20 @@ mod tests { let content = fs::read_to_string(&skill).unwrap(); assert_eq!(content, SPROUT_CLI_SKILL_MD); - // .claude/skills/sprout-cli is a symlink on Unix. + // On unix, harness-specific symlinks should resolve to the canonical dir. #[cfg(unix)] { - let symlink = root.join(".claude/skills/sprout-cli"); - assert!( - symlink.symlink_metadata().unwrap().file_type().is_symlink(), - ".claude/skills/sprout-cli should be a symlink" - ); + for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { + let link = root.join(dir).join("sprout-cli"); + assert!( + link.symlink_metadata().unwrap().file_type().is_symlink(), + "{dir}/sprout-cli should be a symlink" + ); + assert!( + link.join("SKILL.md").exists(), + "symlink at {dir}/sprout-cli should resolve to dir with SKILL.md" + ); + } } } @@ -692,14 +726,18 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - // Real dirs in .agents tree and .claude parents should be 700. - // .claude/skills/sprout-cli is a symlink — excluded from chmod check. + // Canonical path and all provider parent dirs should be locked down. + // Symlinks (e.g. .goose/skills/sprout-cli) are skipped by the chmod loop. for dir in [ ".agents", ".agents/skills", ".agents/skills/sprout-cli", + ".goose", + ".goose/skills", ".claude", ".claude/skills", + ".codex", + ".codex/skills", ] { let path = root.join(dir); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; @@ -776,6 +814,81 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn ensure_skill_symlinks_are_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + ensure_nest_at(&root).unwrap(); + // Second call should succeed without errors. + ensure_nest_at(&root).unwrap(); + // All symlinks still valid and point to relative targets. + for dir in [".goose/skills", ".claude/skills", ".codex/skills"] { + let link = root.join(dir).join("sprout-cli"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert!( + link.join("SKILL.md").exists(), + "symlink at {dir}/sprout-cli should resolve to dir with SKILL.md" + ); + let target = fs::read_link(&link).unwrap(); + assert_eq!( + target.to_str().unwrap(), + format!("../../{CANONICAL_SKILL_DIR}"), + "symlink at {dir}/sprout-cli should use relative target" + ); + } + } + + #[cfg(unix)] + #[test] + fn ensure_skill_symlinks_skip_real_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + // Pre-create a real directory where a symlink would go. + let real_dir = root.join(".claude/skills/sprout-cli"); + fs::create_dir_all(&real_dir).unwrap(); + fs::write(real_dir.join("custom.md"), "user content").unwrap(); + + ensure_nest_at(&root).unwrap(); + + // Real directory should be preserved, not replaced with a symlink. + assert!(real_dir.is_dir()); + assert!(!real_dir + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::read_to_string(real_dir.join("custom.md")).unwrap(), + "user content" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_skill_symlinks_skip_dangling_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + // Pre-create a dangling symlink where the .codex link would go. + let codex_skills = root.join(".codex/skills"); + fs::create_dir_all(&codex_skills).unwrap(); + let dangling = codex_skills.join("sprout-cli"); + std::os::unix::fs::symlink("/nonexistent/target", &dangling).unwrap(); + + ensure_nest_at(&root).unwrap(); + + // Dangling symlink should be left alone (not clobbered). + assert!(dangling + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::read_link(&dangling).unwrap().to_str().unwrap(), + "/nonexistent/target" + ); + } + #[cfg(unix)] #[test] fn ensure_cli_symlink_creates_symlink() { From b963474203aa17ffa2b4c73e67bded11a8913b89 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 19:44:46 -0400 Subject: [PATCH 16/17] style(desktop): remove stale Claude Code references from nest comments Doc comment on SPROUT_CLI_SKILL_MD still said "Claude Code skill" and the ensure_nest_at docstring used .claude/skills as an inline example rather than describing the generic provider pattern. --- desktop/src-tauri/src/managed_agents/nest.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 00c5dc355..b88d12ee7 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -34,7 +34,7 @@ const NEST_DIRS: &[&str] = &[ /// Fully static — no runtime interpolation, no secrets, no user paths. const AGENTS_MD: &str = include_str!("nest_agents.md"); -/// Default SKILL.md content for the sprout-cli Claude Code skill. +/// Default SKILL.md content for the sprout-cli skill. /// Written to ~/.sprout/.agents/skills/sprout-cli/SKILL.md on first init. const SPROUT_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); @@ -72,8 +72,8 @@ pub fn ensure_nest() -> Result<(), String> { /// - Creates the root directory and all subdirectories. /// - Writes `AGENTS.md` only if it doesn't already exist. /// - Writes `.agents/skills/sprout-cli/SKILL.md` only if it doesn't already exist. -/// - Creates harness-specific symlinks (e.g. `.claude/skills/sprout-cli`) pointing -/// to the canonical `.agents/skills/sprout-cli` directory. +/// - Creates harness-specific symlinks pointing to the canonical +/// `.agents/skills/sprout-cli` directory for each known provider. /// - Sets 700 permissions on the root, all subdirectories, and the skill /// directory tree (Unix). /// From 41fbc0be750cb13bf2d0e39a5cab22bcb81174fb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 22 May 2026 20:37:46 -0400 Subject: [PATCH 17/17] fixup: adjust test + size override for merged migration behavior ensure_skill_symlinks_skip_real_directory assumed no migration path; refresh_skill_md_if_stale converts real dirs at .claude/skills/sprout-cli to symlinks, so the test was updated to match the actual merged behavior. Bump nest.rs file-size override to accommodate new provider symlink tests. --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 32 +++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index c3e3acd77..ca00655cc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/nest.rs", 1300], // version-gated AGENTS.md + SKILL.md refresh + .agents/.claude symlink migration + managed section upsert + dynamic agent context + tests + ["src-tauri/src/managed_agents/nest.rs", 1420], // version-gated AGENTS.md + SKILL.md refresh + .agents/.claude symlink migration + ensure_skill_symlinks (all known providers) + managed section upsert + dynamic agent context + tests ["src-tauri/src/managed_agents/personas.rs", 950], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index b88d12ee7..563ac462d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -841,26 +841,36 @@ mod tests { #[cfg(unix)] #[test] - fn ensure_skill_symlinks_skip_real_directory() { + fn ensure_skill_symlinks_skips_existing_path_during_initial_pass() { + // ensure_skill_symlinks skips any path where symlink_metadata succeeds. + // However, refresh_skill_md_if_stale (called after ensure_skill_symlinks) + // migrates pre-existing real directories at .claude/skills/sprout-cli to + // symlinks. This test verifies the end-to-end behavior: a pre-existing real + // dir at the claude path is migrated to a symlink. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); // Pre-create a real directory where a symlink would go. let real_dir = root.join(".claude/skills/sprout-cli"); fs::create_dir_all(&real_dir).unwrap(); - fs::write(real_dir.join("custom.md"), "user content").unwrap(); + // Place SKILL.md so migration preserves it. + fs::write(real_dir.join("SKILL.md"), "custom skill content").unwrap(); ensure_nest_at(&root).unwrap(); - // Real directory should be preserved, not replaced with a symlink. - assert!(real_dir.is_dir()); - assert!(!real_dir - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink()); + // Migration converts the real dir to a symlink; content is moved to canonical path. + assert!( + real_dir + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + ".claude/skills/sprout-cli should be migrated to a symlink" + ); + // The canonical path now holds the migrated content. + let canonical = root.join(".agents/skills/sprout-cli/SKILL.md"); assert_eq!( - fs::read_to_string(real_dir.join("custom.md")).unwrap(), - "user content" + fs::read_to_string(&canonical).unwrap(), + "custom skill content" ); }