diff --git a/desktop/package.json b/desktop/package.json index 69787dd02..52b2737c7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:px-text": "node ./scripts/check-px-text.mjs", + "sync:goose-avatars": "node ./scripts/sync-goose-avatars.mjs", "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text", "format": "biome format --write .", @@ -37,6 +38,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-focus-scope": "^1.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", diff --git a/desktop/scripts/sync-goose-avatars.mjs b/desktop/scripts/sync-goose-avatars.mjs new file mode 100644 index 000000000..05afd9299 --- /dev/null +++ b/desktop/scripts/sync-goose-avatars.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const ARTIFACTORY_BASE = + "https://global.block-artifacts.com/artifactory/goose-internal/avatars"; +const LATEST_URL = `${ARTIFACTORY_BASE}/latest.json`; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = dirname(scriptDir); +const outputRoot = join(desktopRoot, "src/shared/assets/goose-avatars"); +const catalogPath = join(outputRoot, "catalog.json"); + +const FORMATS = ["webm", "hevc"]; + +function variantOutputPath(asset, format) { + const extension = format === "hevc" ? "mp4" : "webm"; + return join( + outputRoot, + format, + asset.collectionId, + `${asset.id}.${extension}`, + ); +} + +function posterOutputPath(asset) { + return join(outputRoot, "posters", asset.collectionId, `${asset.id}.png`); +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return response.json(); +} + +async function fileExistsWithSize(path, byteSize) { + try { + const info = await stat(path); + return info.size === byteSize; + } catch { + return false; + } +} + +async function downloadFile(url, path, byteSize) { + if (await fileExistsWithSize(path, byteSize)) { + return "skipped"; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status}`); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength !== byteSize) { + throw new Error( + `Downloaded ${url} with ${bytes.byteLength} bytes, expected ${byteSize}.`, + ); + } + + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); + return "downloaded"; +} + +async function runFfmpeg(args) { + await new Promise((resolve, reject) => { + const child = spawn("ffmpeg", args, { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `ffmpeg exited with ${code}: ${stderr.trim() || "no stderr"}`, + ), + ); + } + }); + }); +} + +async function ensurePoster(asset) { + const posterPath = posterOutputPath(asset); + try { + await stat(posterPath); + return "skipped"; + } catch { + // Generate it below. + } + + await mkdir(dirname(posterPath), { recursive: true }); + await runFfmpeg([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + variantOutputPath(asset, "webm"), + "-frames:v", + "1", + posterPath, + ]); + return "generated"; +} + +async function main() { + const latest = await fetchJson(LATEST_URL); + const manifest = await fetchJson( + `${ARTIFACTORY_BASE}/${latest.manifestPath}`, + ); + const versionRoot = `${ARTIFACTORY_BASE}/${manifest.catalogVersion}`; + + await mkdir(outputRoot, { recursive: true }); + await writeFile(catalogPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const totals = { + downloaded: 0, + skipped: 0, + postersGenerated: 0, + postersSkipped: 0, + }; + + for (const [index, asset] of manifest.assets.entries()) { + for (const format of FORMATS) { + const variant = asset.variants[format]; + const sourceUrl = `${versionRoot}/${variant.path}`; + const result = await downloadFile( + sourceUrl, + variantOutputPath(asset, format), + variant.byteSize, + ); + totals[result] += 1; + } + + const posterResult = await ensurePoster(asset); + if (posterResult === "generated") { + totals.postersGenerated += 1; + } else { + totals.postersSkipped += 1; + } + + const completed = index + 1; + if (completed % 5 === 0 || completed === manifest.assets.length) { + console.log( + `Synced ${completed}/${manifest.assets.length} Goose avatars...`, + ); + } + } + + const catalogBytes = await readFile(catalogPath, "utf8"); + JSON.parse(catalogBytes); + + console.log( + `Done. Downloaded ${totals.downloaded}, skipped ${totals.skipped}, generated ${totals.postersGenerated} posters, skipped ${totals.postersSkipped} posters.`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 1c8b9925f..2282c15b0 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -17,6 +17,12 @@ use crate::{ util::now_iso, }; +fn trim_optional(value: Option) -> Option { + value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + /// Query available models from an agent via `buzz-acp models --json`. /// /// Spawns a short-lived subprocess (no relay connection needed). The subprocess @@ -139,7 +145,8 @@ pub async fn get_agent_models( /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, /// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. +/// Name and avatar changes are synced to the relay immediately via a kind:0 +/// re-publish. #[tauri::command] pub async fn update_managed_agent( input: UpdateManagedAgentRequest, @@ -162,6 +169,7 @@ pub async fn update_managed_agent( let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let mut name_changed = false; + let mut avatar_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); if !trimmed.is_empty() && trimmed != record.name { @@ -169,6 +177,15 @@ pub async fn update_managed_agent( name_changed = true; } } + if let Some(avatar_update) = input.avatar_url { + let normalized = trim_optional(avatar_update); + let avatar_url_cleared = normalized.is_none(); + if normalized != record.avatar_url || avatar_url_cleared != record.avatar_url_cleared { + record.avatar_url = normalized; + record.avatar_url_cleared = avatar_url_cleared; + avatar_changed = true; + } + } if let Some(model_update) = input.model { record.model = model_update; } @@ -245,15 +262,19 @@ pub async fn update_managed_agent( .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - let sync_params = if name_changed { + let sync_params = if name_changed || avatar_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; let relay_url = record.relay_url.clone(); let display_name = record.name.clone(); - let avatar_url = record - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&record.agent_command)); + let avatar_url = if avatar_changed || record.avatar_url_cleared { + record.avatar_url.clone() + } else { + record + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&record.agent_command)) + }; let auth_tag = record.auth_tag.clone(); Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) } else { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dc64dc826..423b847ae 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -71,6 +71,17 @@ fn resolve_created_avatar_url( .or_else(|| managed_agent_avatar_url(agent_command)) } +fn is_retired_fizz_data_url(persona_id: Option<&str>, avatar_url: &str) -> bool { + persona_id == Some("builtin:fizz") && avatar_url.trim_start().starts_with("data:image/") +} + +fn filter_retired_fizz_avatar( + persona_id: Option<&str>, + avatar_url: Option, +) -> Option { + avatar_url.filter(|url| !is_retired_fizz_data_url(persona_id, url)) +} + #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( app: &AppHandle, @@ -521,6 +532,7 @@ pub async fn create_managed_agent( auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), avatar_url: resolved_avatar_url.clone(), + avatar_url_cleared: false, acp_command: input .acp_command .as_deref() @@ -732,10 +744,11 @@ pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, - /// Expected avatar URL for the published profile. `None` for legacy records - /// that predate the `avatar_url` field — these will be backfilled from the - /// relay's existing kind:0 profile on first reconciliation. + /// Expected avatar URL for the published profile. `None` can mean either a + /// legacy missing value or an explicit clear; `avatar_url_cleared` + /// disambiguates those cases. pub(crate) avatar_url: Option, + pub(crate) avatar_url_cleared: bool, pub(crate) auth_tag: Option, /// The agent's pubkey (hex). Needed to update the persisted record during /// avatar backfill migration. @@ -791,6 +804,7 @@ pub async fn start_managed_agent( name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), + avatar_url_cleared: record.avatar_url_cleared, auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), agent_command: record.agent_command.clone(), @@ -896,6 +910,17 @@ fn resolve_legacy_avatar( .unwrap_or_default() } +fn should_skip_legacy_command_avatar( + stored_avatar_was_retired_fizz: bool, + relay_picture_was_retired_fizz: bool, + persona_avatar: Option<&str>, + relay_picture: Option<&str>, +) -> bool { + (stored_avatar_was_retired_fizz || relay_picture_was_retired_fizz) + && persona_avatar.is_none() + && relay_picture.is_none() +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing @@ -922,50 +947,88 @@ pub(crate) async fn reconcile_agent_profile( // Query the relay for the agent's existing kind:0 profile. let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?; - // Resolve the expected avatar — backfilling for legacy records that have no - // stored avatar_url yet. - let expected_avatar = match data.avatar_url.as_deref() { - Some(url) => url.to_string(), - None => { - // Legacy record: the relay profile may have been corrupted by the - // old reconciliation code (it overwrote the persona avatar with the - // command default), so the persona record is the authoritative source. - let persona_avatar = data.persona_id.as_ref().and_then(|pid| { - load_personas(app) - .ok()? - .into_iter() - .find(|p| p.id == *pid)? - .avatar_url - }); - - let backfilled = resolve_legacy_avatar( - persona_avatar, - existing.as_ref().and_then(|info| info.picture.clone()), - &data.agent_command, - ); + // Resolve the expected avatar. A user-initiated clear is intentionally + // `None` and must not be backfilled from persona/relay/runtime defaults. + // For legacy records that have no stored avatar_url yet, `None` still means + // backfill from the best available historical source. + let stored_avatar = + filter_retired_fizz_avatar(data.persona_id.as_deref(), data.avatar_url.clone()); + let stored_avatar_was_retired_fizz = data + .avatar_url + .as_deref() + .is_some_and(|url| is_retired_fizz_data_url(data.persona_id.as_deref(), url)); + let expected_avatar = if data.avatar_url_cleared && stored_avatar.is_none() { + None + } else { + match stored_avatar { + Some(url) => Some(url.to_string()), + None => { + // Legacy record: the relay profile may have been corrupted by the + // old reconciliation code (it overwrote the persona avatar with the + // command default), so the persona record is the authoritative source. + let persona_avatar = filter_retired_fizz_avatar( + data.persona_id.as_deref(), + data.persona_id.as_ref().and_then(|pid| { + load_personas(app) + .ok()? + .into_iter() + .find(|p| p.id == *pid)? + .avatar_url + }), + ); + let relay_picture_raw = existing.as_ref().and_then(|info| info.picture.clone()); + let relay_picture_was_retired_fizz = relay_picture_raw + .as_deref() + .is_some_and(|url| is_retired_fizz_data_url(data.persona_id.as_deref(), url)); + let relay_picture = + filter_retired_fizz_avatar(data.persona_id.as_deref(), relay_picture_raw); + + let skip_command_fallback = should_skip_legacy_command_avatar( + stored_avatar_was_retired_fizz, + relay_picture_was_retired_fizz, + persona_avatar.as_deref(), + relay_picture.as_deref(), + ); + let backfilled = if skip_command_fallback { + String::new() + } else { + resolve_legacy_avatar(persona_avatar, relay_picture, &data.agent_command) + }; + + // Persist the backfilled avatar so this migration only runs once, + // or clear the retired built-in Fizz data URL if there is no + // current profile image to backfill. + let should_persist_avatar = stored_avatar_was_retired_fizz + || relay_picture_was_retired_fizz + || (!backfilled.is_empty() + && data.avatar_url.as_deref() != Some(backfilled.as_str())); + if should_persist_avatar { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { + record.avatar_url = if backfilled.is_empty() { + None + } else { + Some(backfilled.clone()) + }; + record.avatar_url_cleared = backfilled.is_empty(); + save_managed_agents(app, &records)?; + } + } - // Persist the backfilled avatar so this migration only runs once. - if !backfilled.is_empty() { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { - record.avatar_url = Some(backfilled.clone()); - save_managed_agents(app, &records)?; + if backfilled.is_empty() { + None + } else { + Some(backfilled) } } - - backfilled } }; - if expected_avatar.is_empty() { - return Ok(()); - } - - if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) { + if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { return Ok(()); } @@ -977,7 +1040,7 @@ pub(crate) async fn reconcile_agent_profile( &data.relay_url, &agent_keys, &data.name, - Some(&expected_avatar), + expected_avatar.as_deref(), data.auth_tag.as_deref(), ) .await diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f141c6ae5..0e92f81e9 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -70,6 +70,33 @@ fn created_avatar_uses_command_fallback_without_input_or_persona() { assert_eq!(resolved, managed_agent_avatar_url("goose")); } +#[test] +fn retired_fizz_data_url_is_treated_as_absent() { + assert_eq!( + filter_retired_fizz_avatar( + Some("builtin:fizz"), + Some("data:image/png;base64,old-demo".to_string()), + ), + None, + ); + assert_eq!( + filter_retired_fizz_avatar( + Some("custom:fizz"), + Some("data:image/png;base64,user-avatar".to_string()), + ) + .as_deref(), + Some("data:image/png;base64,user-avatar"), + ); + assert_eq!( + filter_retired_fizz_avatar( + Some("builtin:fizz"), + Some("https://relay.example/avatar.png".to_string()), + ) + .as_deref(), + Some("https://relay.example/avatar.png"), + ); +} + fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProfileInfo { crate::relay::AgentProfileInfo { display_name: name.map(str::to_string), @@ -169,3 +196,29 @@ fn legacy_avatar_empty_when_nothing_resolves() { assert!(resolved.is_empty()); } + +#[test] +fn legacy_avatar_skips_command_icon_for_retired_stored_fizz_avatar() { + assert!(should_skip_legacy_command_avatar(true, false, None, None)); +} + +#[test] +fn legacy_avatar_skips_command_icon_for_retired_relay_fizz_avatar() { + assert!(should_skip_legacy_command_avatar(false, true, None, None)); +} + +#[test] +fn legacy_avatar_keeps_command_icon_when_retired_fizz_has_current_avatar_source() { + assert!(!should_skip_legacy_command_avatar( + false, + true, + Some("https://x/persona.png"), + None, + )); + assert!(!should_skip_legacy_command_avatar( + false, + true, + None, + Some("https://x/relay.png"), + )); +} diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 56f6742f4..db8df524e 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -298,9 +298,11 @@ pub fn parse_persona_files( }); } - // .persona.md: YAML frontmatter starts with "---" + // Persona markdown: YAML frontmatter starts with "---". + // Goose Internal exports plain .md agent files, while Buzz historically + // used .persona.md; parse both through the same validated importer. let lower_name = file_name.to_ascii_lowercase(); - if lower_name.ends_with(".persona.md") { + if lower_name.ends_with(".md") { if file_bytes.len() > MAX_JSON_BYTES { return Err("Markdown file is too large (max 5 MB).".to_string()); } @@ -312,17 +314,7 @@ pub fn parse_persona_files( }); } - // If it's a .md file but not .persona.md, give a specific hint. - if lower_name.ends_with(".md") { - return Err( - "Only .persona.md files are supported. Rename to .persona.md".to_string(), - ); - } - - Err( - "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip" - .to_string(), - ) + Err("Unsupported file format. Expected .md, .persona.png, .persona.json, or .zip".to_string()) } #[tauri::command] @@ -373,3 +365,31 @@ pub async fn export_persona_to_json( let filename = format!("{slug}.persona.json"); save_json_with_dialog(&app, &filename, &json_bytes).await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_persona_files_accepts_plain_md_with_avatar_ref() { + let md = br#"--- +name: fizz +display_name: Fizz +avatar: app-avatar:pollies-12 +runtime: goose +--- +You are Fizz. +"#; + + let result = parse_persona_files(md.to_vec(), "fizz.md".to_string()).unwrap(); + + assert_eq!(result.personas.len(), 1); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Fizz"); + assert_eq!( + result.personas[0].avatar_ref.as_deref(), + Some("app-avatar:pollies-12") + ); + assert_eq!(result.personas[0].source_file, "fizz.md"); + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8b1f3c62d..b21d965c8 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -964,6 +964,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + avatar_url_cleared: false, acp_command: String::new(), agent_command: String::new(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 68d9b30a4..abb5803b6 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use png::Decoder; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::io::{Cursor, Read}; @@ -13,6 +13,7 @@ pub struct ParsedPersonaPreview { pub display_name: String, pub system_prompt: String, pub avatar_data_url: Option, + pub avatar_ref: Option, pub runtime: Option, pub model: Option, pub provider: Option, @@ -81,6 +82,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result Result Result { let content = - std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in .persona.md: {e}"))?; - let config = buzz_persona_pkg::persona::parse_persona_md(content) - .map_err(|e| format!("Failed to parse .persona.md: {e}"))?; - - // Split "provider:model" into separate fields for the preview. - let model = match config.model.as_deref() { - Some(s) if !s.is_empty() => { - let (_prov, id) = buzz_persona_pkg::persona::split_model(s); - Some(id.to_owned()) - } - _ => None, - }; + std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in Markdown: {e}"))?; + match buzz_persona_pkg::persona::parse_persona_md(content) { + Ok(config) => Ok(parsed_preview_from_md_config(config)), + Err(strict_err) => parse_lenient_md_persona(content) + .map_err(|_| format!("Failed to parse persona Markdown: {strict_err}")), + } +} - Ok(ParsedPersonaPreview { +fn parsed_preview_from_md_config( + config: buzz_persona_pkg::persona::PersonaConfig, +) -> ParsedPersonaPreview { + let (provider, model) = split_preview_model(config.model.as_deref()); + + ParsedPersonaPreview { display_name: config.display_name, system_prompt: config.prompt, - avatar_data_url: None, // .persona.md avatars are paths, not data URIs + avatar_data_url: None, // Markdown avatars are paths, not data URIs + avatar_ref: config.avatar, runtime: config.runtime, model, - provider: None, // .persona.md format does not carry llmProvider + provider, + name_pool: Vec::new(), + source_file: String::new(), + } +} + +fn split_preview_model(model: Option<&str>) -> (Option, Option) { + match model.map(str::trim).filter(|s| !s.is_empty()) { + Some(raw_model) => { + let (provider, id) = buzz_persona_pkg::persona::split_model(raw_model); + (provider.map(str::to_owned), Some(id.to_owned())) + } + None => (None, None), + } +} + +#[derive(Debug, Deserialize)] +struct LenientMdFrontmatter { + name: Option, + display_name: Option, + avatar: Option, + runtime: Option, + model: Option, +} + +fn parse_lenient_md_persona(content: &str) -> Result { + let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content) + .map_err(|e| format!("Missing frontmatter: {e}"))?; + let fields: LenientMdFrontmatter = + serde_yaml::from_str(frontmatter).map_err(|e| format!("Invalid YAML frontmatter: {e}"))?; + let display_name = fields + .display_name + .or(fields.name) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "Missing display name".to_string())?; + let (provider, model) = split_preview_model(fields.model.as_deref()); + + Ok(ParsedPersonaPreview { + display_name, + system_prompt: body.to_string(), + avatar_data_url: None, // Markdown avatars are paths, not data URIs + avatar_ref: fields.avatar, + runtime: fields.runtime, + model, + provider, name_pool: Vec::new(), source_file: String::new(), }) @@ -405,6 +454,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result Result Result Result Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk(keyword.to_string(), text.to_string()) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: build a PNG with a buzz_persona_pkg tEXt chunk for the given name/prompt. - fn make_test_persona_png(name: &str, prompt: &str) -> Vec { - let payload = serde_json::json!({ - "version": 1, - "displayName": name, - "systemPrompt": prompt, - }); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - make_png_with_text("buzz_persona_pkg", &b64) - } - - /// Helper: build a plain PNG with no metadata. - fn make_plain_png() -> Vec { - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - buf - } - - /// Helper: create a ZIP from name→data pairs. - fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { - let mut buf = Cursor::new(Vec::new()); - let mut zip = ZipWriter::new(&mut buf); - let options = SimpleFileOptions::default(); - for (name, data) in entries { - zip.start_file(*name, options).unwrap(); - zip.write_all(data).unwrap(); - } - zip.finish().unwrap(); - buf.into_inner() - } - - #[test] - fn parse_png_round_trip() { - let png = make_test_persona_png("George Costanza", "You are George."); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "George Costanza"); - assert_eq!(result.system_prompt, "You are George."); - assert!(result - .avatar_data_url - .unwrap() - .starts_with("data:image/png;base64,")); - } - - #[test] - fn parse_png_no_metadata() { - let png = make_plain_png(); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("doesn't contain persona data")); - } - - #[test] - fn parse_png_unknown_version() { - let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_png_malformed_base64() { - let png = make_png_with_text("buzz_persona_pkg", "!!!not-base64!!!"); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid base64")); - } - - #[test] - fn parse_png_malformed_json() { - let b64 = STANDARD.encode(b"not json at all"); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_png_empty_fields() { - let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); - let b64 = STANDARD.encode(payload.to_string().as_bytes()); - let png = make_png_with_text("buzz_persona_pkg", &b64); - let err = parse_png_persona(&png).unwrap_err(); - assert!(err.contains("displayName is empty")); - } - - #[test] - fn parse_png_chara_fallback() { - let chara = serde_json::json!({ - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": { - "name": "Kramer", - "system_prompt": "You are Kramer.", - "description": "" - } - }); - let b64 = STANDARD.encode(chara.to_string().as_bytes()); - let png = make_png_with_text("chara", &b64); - let result = parse_png_persona(&png).unwrap(); - assert_eq!(result.display_name, "Kramer"); - assert_eq!(result.system_prompt, "You are Kramer."); - } - - #[test] - fn parse_png_chara_ignored_when_buzz_present() { - // Build a PNG with both buzz_persona_pkg and chara chunks. - let buzz = serde_json::json!({"version": 1, "displayName": "Buzz Name", "systemPrompt": "Buzz prompt"}); - let chara = serde_json::json!({ - "spec": "chara_card_v2", "spec_version": "2.0", - "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} - }); - let buzz_b64 = STANDARD.encode(buzz.to_string().as_bytes()); - let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), buzz_b64) - .unwrap(); - enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "Buzz Name"); - assert_eq!(result.system_prompt, "Buzz prompt"); - } - - #[test] - fn parse_zip_valid_pack() { - let p1 = make_test_persona_png("Alice", "Prompt A"); - let p2 = make_test_persona_png("Bob", "Prompt B"); - let p3 = make_test_persona_png("Carol", "Prompt C"); - let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 3); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].source_file, "alice.png"); - } - - #[test] - fn parse_zip_mixed() { - let valid1 = make_test_persona_png("Alice", "Prompt A"); - let valid2 = make_test_persona_png("Bob", "Prompt B"); - let bad_png = make_plain_png(); // no metadata - let zip = make_test_zip(&[ - ("alice.png", &valid1), - ("bob.png", &valid2), - ("bad.png", &bad_png), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert_eq!(result.skipped.len(), 2); - } - - #[test] - fn parse_zip_no_pngs() { - let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("No persona files found")); - } - - #[test] - fn parse_zip_exceeds_entry_limit() { - let png = make_test_persona_png("X", "Y"); - let entries: Vec<(String, &[u8])> = (0..51) - .map(|i| (format!("{i}.png"), png.as_slice())) - .collect(); - let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); - let zip = make_test_zip(&refs); - let err = parse_zip_personas(&zip).unwrap_err(); - assert!(err.contains("too many entries")); - } - - #[test] - fn parse_zip_path_traversal() { - let valid = make_test_persona_png("Safe", "Prompt"); - let evil = make_test_persona_png("Evil", "Prompt"); - let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 1); - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0].reason.contains("Path traversal")); - } - - #[test] - fn parse_png_duplicate_chunks() { - // Two buzz_persona_pkg chunks — should use the first and ignore the second. - let payload1 = - serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); - let payload2 = - serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); - let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); - let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); - - let mut buf = Vec::new(); - { - let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); - enc.set_color(ColorType::Rgba); - enc.set_depth(BitDepth::Eight); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_1) - .unwrap(); - enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_2) - .unwrap(); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0, 0, 0, 255]).unwrap(); - } - - let result = parse_png_persona(&buf).unwrap(); - assert_eq!(result.display_name, "First"); - assert_eq!(result.system_prompt, "Prompt 1"); - } - - #[test] - fn parse_zip_exceeds_size_limit() { - // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. - let mut zip_buf = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut zip_buf); - let options = SimpleFileOptions::default(); - zip.start_file("big.png", options).unwrap(); - let chunk = vec![0u8; 1024 * 1024]; // 1 MB - for _ in 0..101 { - zip.write_all(&chunk).unwrap(); - } - zip.finish().unwrap(); - } - let zip_bytes = zip_buf.into_inner(); - let err = parse_zip_personas(&zip_bytes).unwrap_err(); - assert!(err.contains("exceeds 100MB")); - } - - #[test] - fn parse_json_round_trip() { - let bytes = encode_persona_json( - "Ada Lovelace", - "You are Ada.", - Some("https://example.com/ada.png"), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Ada Lovelace"); - assert_eq!(result.system_prompt, "You are Ada."); - assert_eq!( - result.avatar_data_url.as_deref(), - Some("https://example.com/ada.png") - ); - assert!(result.source_file.is_empty()); - } - - #[test] - fn parse_json_round_trip_no_avatar() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert_eq!(result.system_prompt, "You are Bob."); - assert!(result.avatar_data_url.is_none()); - } - - #[test] - fn parse_json_round_trip_data_uri_avatar() { - let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; - let bytes = encode_persona_json( - "Carol", - "You are Carol.", - Some(data_uri), - None, - None, - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Carol"); - assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); - } - - #[test] - fn parse_json_round_trip_with_runtime_and_model() { - let bytes = encode_persona_json( - "Agent Smith", - "You are an agent.", - None, - Some("goose"), - Some("claude-sonnet-4"), - None, - &[], - ) - .unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Agent Smith"); - assert_eq!(result.system_prompt, "You are an agent."); - assert!(result.avatar_data_url.is_none()); - assert_eq!(result.runtime.as_deref(), Some("goose")); - assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); - } - - #[test] - fn parse_json_round_trip_without_runtime_and_model() { - let bytes = - encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Bob"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_no_runtime_model_fields() { - // Simulate a legacy persona JSON without runtime/model fields - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Persona", - "systemPrompt": "Old school prompt" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.display_name, "Legacy Persona"); - assert_eq!(result.system_prompt, "Old school prompt"); - assert!(result.runtime.is_none()); - assert!(result.model.is_none()); - } - - #[test] - fn parse_json_backward_compat_legacy_provider_key() { - // A JSON card written with the old "provider" key should still parse. - let json = serde_json::json!({ - "version": 1, - "displayName": "Legacy Agent", - "systemPrompt": "Old prompt", - "provider": "goose" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let result = parse_json_persona(&bytes).unwrap(); - assert_eq!(result.runtime.as_deref(), Some("goose")); - } - - #[test] - fn parse_json_invalid_version() { - let json = serde_json::json!({ - "version": 99, - "displayName": "X", - "systemPrompt": "Y" - }); - let bytes = serde_json::to_vec(&json).unwrap(); - let err = parse_json_persona(&bytes).unwrap_err(); - assert!(err.contains("Unsupported persona version")); - } - - #[test] - fn parse_json_empty_fields() { - let json_empty_name = serde_json::json!({ - "version": 1, - "displayName": "", - "systemPrompt": "Y" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); - assert!(err.contains("displayName is empty")); - - let json_empty_prompt = serde_json::json!({ - "version": 1, - "displayName": "X", - "systemPrompt": "" - }); - let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); - assert!(err.contains("systemPrompt is empty")); - } - - #[test] - fn parse_json_malformed() { - let err = parse_json_persona(b"not json at all").unwrap_err(); - assert!(err.contains("Invalid JSON")); - } - - #[test] - fn parse_zip_with_json() { - let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap(); - let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - assert!(result.skipped.is_empty()); - assert_eq!(result.personas[0].display_name, "Alice"); - assert_eq!(result.personas[1].display_name, "Bob"); - } - - #[test] - fn parse_zip_mixed_png_and_json() { - let png = make_test_persona_png("PngPersona", "PNG prompt"); - let json = - encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("persona.png", &png), - ("persona.json", &json), - ("readme.txt", b"hello"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // readme.txt should be skipped - assert_eq!(result.skipped.len(), 1); - assert!(result.skipped[0] - .reason - .contains("Not a .png, .json, or .persona.md file")); - } - - #[test] - fn parse_zip_ignores_macos_resource_forks() { - let j1 = - encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap(); - let j2 = - encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap(); - let zip = make_test_zip(&[ - ("frank-costanza.persona.json", &j1), - ("jackie-chiles.persona.json", &j2), - ("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"), - ("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"), - ]); - let result = parse_zip_personas(&zip).unwrap(); - assert_eq!(result.personas.len(), 2); - // macOS resource forks should be silently ignored, not skipped with errors - assert!(result.skipped.is_empty()); - } -} +#[path = "persona_card_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_card_tests.rs b/desktop/src-tauri/src/managed_agents/persona_card_tests.rs new file mode 100644 index 000000000..6bcf92481 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_card_tests.rs @@ -0,0 +1,530 @@ +use super::*; +use png::{BitDepth, ColorType, Encoder}; +use std::io::Write; +use zip::write::{SimpleFileOptions, ZipWriter}; + +/// Helper: build a minimal valid PNG with a custom tEXt chunk. +fn make_png_with_text(keyword: &str, text: &str) -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk(keyword.to_string(), text.to_string()) + .unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf +} + +/// Helper: build a PNG with a buzz_persona_pkg tEXt chunk for the given name/prompt. +fn make_test_persona_png(name: &str, prompt: &str) -> Vec { + let payload = serde_json::json!({ + "version": 1, + "displayName": name, + "systemPrompt": prompt, + }); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + make_png_with_text("buzz_persona_pkg", &b64) +} + +/// Helper: build a plain PNG with no metadata. +fn make_plain_png() -> Vec { + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + buf +} + +/// Helper: create a ZIP from name→data pairs. +fn make_test_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(&mut buf); + let options = SimpleFileOptions::default(); + for (name, data) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn parse_png_round_trip() { + let png = make_test_persona_png("George Costanza", "You are George."); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "George Costanza"); + assert_eq!(result.system_prompt, "You are George."); + assert!(result + .avatar_data_url + .unwrap() + .starts_with("data:image/png;base64,")); +} + +#[test] +fn parse_png_no_metadata() { + let png = make_plain_png(); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("doesn't contain persona data")); +} + +#[test] +fn parse_png_unknown_version() { + let payload = serde_json::json!({"version": 99, "displayName": "X", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Unsupported persona version")); +} + +#[test] +fn parse_png_malformed_base64() { + let png = make_png_with_text("buzz_persona_pkg", "!!!not-base64!!!"); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid base64")); +} + +#[test] +fn parse_png_malformed_json() { + let b64 = STANDARD.encode(b"not json at all"); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("Invalid JSON")); +} + +#[test] +fn parse_png_empty_fields() { + let payload = serde_json::json!({"version": 1, "displayName": "", "systemPrompt": "Y"}); + let b64 = STANDARD.encode(payload.to_string().as_bytes()); + let png = make_png_with_text("buzz_persona_pkg", &b64); + let err = parse_png_persona(&png).unwrap_err(); + assert!(err.contains("displayName is empty")); +} + +#[test] +fn parse_png_chara_fallback() { + let chara = serde_json::json!({ + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "Kramer", + "system_prompt": "You are Kramer.", + "description": "" + } + }); + let b64 = STANDARD.encode(chara.to_string().as_bytes()); + let png = make_png_with_text("chara", &b64); + let result = parse_png_persona(&png).unwrap(); + assert_eq!(result.display_name, "Kramer"); + assert_eq!(result.system_prompt, "You are Kramer."); +} + +#[test] +fn parse_png_chara_ignored_when_buzz_present() { + // Build a PNG with both buzz_persona_pkg and chara chunks. + let buzz = serde_json::json!({"version": 1, "displayName": "Buzz Name", "systemPrompt": "Buzz prompt"}); + let chara = serde_json::json!({ + "spec": "chara_card_v2", "spec_version": "2.0", + "data": {"name": "Chara Name", "system_prompt": "Chara prompt", "description": ""} + }); + let buzz_b64 = STANDARD.encode(buzz.to_string().as_bytes()); + let chara_b64 = STANDARD.encode(chara.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("buzz_persona_pkg".to_string(), buzz_b64) + .unwrap(); + enc.add_text_chunk("chara".to_string(), chara_b64).unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "Buzz Name"); + assert_eq!(result.system_prompt, "Buzz prompt"); +} + +#[test] +fn parse_zip_valid_pack() { + let p1 = make_test_persona_png("Alice", "Prompt A"); + let p2 = make_test_persona_png("Bob", "Prompt B"); + let p3 = make_test_persona_png("Carol", "Prompt C"); + let zip = make_test_zip(&[("alice.png", &p1), ("bob.png", &p2), ("carol.png", &p3)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 3); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].source_file, "alice.png"); +} + +#[test] +fn parse_zip_mixed() { + let valid1 = make_test_persona_png("Alice", "Prompt A"); + let valid2 = make_test_persona_png("Bob", "Prompt B"); + let bad_png = make_plain_png(); // no metadata + let zip = make_test_zip(&[ + ("alice.png", &valid1), + ("bob.png", &valid2), + ("bad.png", &bad_png), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert_eq!(result.skipped.len(), 2); +} + +#[test] +fn parse_zip_no_pngs() { + let zip = make_test_zip(&[("readme.txt", b"hello"), ("data.csv", b"a,b")]); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("No persona files found")); +} + +#[test] +fn parse_zip_exceeds_entry_limit() { + let png = make_test_persona_png("X", "Y"); + let entries: Vec<(String, &[u8])> = (0..51) + .map(|i| (format!("{i}.png"), png.as_slice())) + .collect(); + let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, d)| (n.as_str(), *d)).collect(); + let zip = make_test_zip(&refs); + let err = parse_zip_personas(&zip).unwrap_err(); + assert!(err.contains("too many entries")); +} + +#[test] +fn parse_zip_path_traversal() { + let valid = make_test_persona_png("Safe", "Prompt"); + let evil = make_test_persona_png("Evil", "Prompt"); + let zip = make_test_zip(&[("safe.png", &valid), ("../evil.png", &evil)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 1); + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0].reason.contains("Path traversal")); +} + +#[test] +fn parse_png_duplicate_chunks() { + // Two buzz_persona_pkg chunks — should use the first and ignore the second. + let payload1 = + serde_json::json!({"version": 1, "displayName": "First", "systemPrompt": "Prompt 1"}); + let payload2 = + serde_json::json!({"version": 1, "displayName": "Second", "systemPrompt": "Prompt 2"}); + let b64_1 = STANDARD.encode(payload1.to_string().as_bytes()); + let b64_2 = STANDARD.encode(payload2.to_string().as_bytes()); + + let mut buf = Vec::new(); + { + let mut enc = Encoder::new(Cursor::new(&mut buf), 1, 1); + enc.set_color(ColorType::Rgba); + enc.set_depth(BitDepth::Eight); + enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_1) + .unwrap(); + enc.add_text_chunk("buzz_persona_pkg".to_string(), b64_2) + .unwrap(); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + + let result = parse_png_persona(&buf).unwrap(); + assert_eq!(result.display_name, "First"); + assert_eq!(result.system_prompt, "Prompt 1"); +} + +#[test] +fn parse_zip_exceeds_size_limit() { + // Create a ZIP with entries whose cumulative decompressed size exceeds 100MB. + let mut zip_buf = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut zip_buf); + let options = SimpleFileOptions::default(); + zip.start_file("big.png", options).unwrap(); + let chunk = vec![0u8; 1024 * 1024]; // 1 MB + for _ in 0..101 { + zip.write_all(&chunk).unwrap(); + } + zip.finish().unwrap(); + } + let zip_bytes = zip_buf.into_inner(); + let err = parse_zip_personas(&zip_bytes).unwrap_err(); + assert!(err.contains("exceeds 100MB")); +} + +#[test] +fn parse_json_round_trip() { + let bytes = encode_persona_json( + "Ada Lovelace", + "You are Ada.", + Some("https://example.com/ada.png"), + None, + None, + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Ada Lovelace"); + assert_eq!(result.system_prompt, "You are Ada."); + assert_eq!( + result.avatar_data_url.as_deref(), + Some("https://example.com/ada.png") + ); + assert!(result.source_file.is_empty()); +} + +#[test] +fn parse_json_round_trip_no_avatar() { + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Bob"); + assert_eq!(result.system_prompt, "You are Bob."); + assert!(result.avatar_data_url.is_none()); +} + +#[test] +fn parse_json_round_trip_data_uri_avatar() { + let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; + let bytes = encode_persona_json( + "Carol", + "You are Carol.", + Some(data_uri), + None, + None, + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Carol"); + assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri)); +} + +#[test] +fn parse_json_round_trip_with_runtime_and_model() { + let bytes = encode_persona_json( + "Agent Smith", + "You are an agent.", + None, + Some("goose"), + Some("claude-sonnet-4"), + None, + &[], + ) + .unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Agent Smith"); + assert_eq!(result.system_prompt, "You are an agent."); + assert!(result.avatar_data_url.is_none()); + assert_eq!(result.runtime.as_deref(), Some("goose")); + assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); +} + +#[test] +fn parse_json_round_trip_without_runtime_and_model() { + let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None, None, &[]).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Bob"); + assert!(result.runtime.is_none()); + assert!(result.model.is_none()); +} + +#[test] +fn parse_json_backward_compat_no_runtime_model_fields() { + // Simulate a legacy persona JSON without runtime/model fields + let json = serde_json::json!({ + "version": 1, + "displayName": "Legacy Persona", + "systemPrompt": "Old school prompt" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.display_name, "Legacy Persona"); + assert_eq!(result.system_prompt, "Old school prompt"); + assert!(result.runtime.is_none()); + assert!(result.model.is_none()); +} + +#[test] +fn parse_json_backward_compat_legacy_provider_key() { + // A JSON card written with the old "provider" key should still parse. + let json = serde_json::json!({ + "version": 1, + "displayName": "Legacy Agent", + "systemPrompt": "Old prompt", + "provider": "goose" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let result = parse_json_persona(&bytes).unwrap(); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_json_invalid_version() { + let json = serde_json::json!({ + "version": 99, + "displayName": "X", + "systemPrompt": "Y" + }); + let bytes = serde_json::to_vec(&json).unwrap(); + let err = parse_json_persona(&bytes).unwrap_err(); + assert!(err.contains("Unsupported persona version")); +} + +#[test] +fn parse_json_empty_fields() { + let json_empty_name = serde_json::json!({ + "version": 1, + "displayName": "", + "systemPrompt": "Y" + }); + let err = parse_json_persona(&serde_json::to_vec(&json_empty_name).unwrap()).unwrap_err(); + assert!(err.contains("displayName is empty")); + + let json_empty_prompt = serde_json::json!({ + "version": 1, + "displayName": "X", + "systemPrompt": "" + }); + let err = parse_json_persona(&serde_json::to_vec(&json_empty_prompt).unwrap()).unwrap_err(); + assert!(err.contains("systemPrompt is empty")); +} + +#[test] +fn parse_json_malformed() { + let err = parse_json_persona(b"not json at all").unwrap_err(); + assert!(err.contains("Invalid JSON")); +} + +#[test] +fn parse_md_persona_preserves_app_avatar_ref() { + let md = br#"--- +name: goosey +display_name: Goosey +description: Goose internal agent. +avatar: app-avatar:gloopies-19 +model: anthropic:claude-sonnet-4 +runtime: goose +--- +You are Goosey. +"#; + let result = parse_md_persona(md).unwrap(); + assert_eq!(result.display_name, "Goosey"); + assert_eq!(result.avatar_data_url, None); + assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19")); + assert_eq!(result.model.as_deref(), Some("claude-sonnet-4")); + assert_eq!(result.provider.as_deref(), Some("anthropic")); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_lenient_md_persona_preserves_model_provider_prefix() { + let md = r#"--- +display_name: Lenient Agent +model: databricks:gpt-5 +runtime: goose +--- +You are lenient. +"#; + + let result = parse_lenient_md_persona(md).unwrap(); + assert_eq!(result.display_name, "Lenient Agent"); + assert_eq!(result.model.as_deref(), Some("gpt-5")); + assert_eq!(result.provider.as_deref(), Some("databricks")); + assert_eq!(result.runtime.as_deref(), Some("goose")); +} + +#[test] +fn parse_md_persona_accepts_goose_internal_frontmatter() { + let md = br#"--- +name: block.md +description: Opinionated guide to Block's intelligence operating model. +avatar: app-avatar:gloopies-19 +metadata: + gooseInternalBundled: true +--- +You are block.md. +"#; + let result = parse_md_persona(md).unwrap(); + assert_eq!(result.display_name, "block.md"); + assert_eq!(result.avatar_ref.as_deref(), Some("app-avatar:gloopies-19")); + assert_eq!(result.system_prompt, "You are block.md.\n"); +} + +#[test] +fn parse_zip_with_json() { + let j1 = encode_persona_json("Alice", "Prompt A", None, None, None, None, &[]).unwrap(); + let j2 = encode_persona_json("Bob", "Prompt B", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Alice"); + assert_eq!(result.personas[1].display_name, "Bob"); +} + +#[test] +fn parse_zip_mixed_png_and_json() { + let png = make_test_persona_png("PngPersona", "PNG prompt"); + let json = + encode_persona_json("JsonPersona", "JSON prompt", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[ + ("persona.png", &png), + ("persona.json", &json), + ("readme.txt", b"hello"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + // readme.txt should be skipped + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0] + .reason + .contains("Not a .png, .json, or .md file")); +} + +#[test] +fn parse_zip_with_plain_md_persona_preserves_avatar_ref() { + let md = br#"--- +name: fizz +display_name: Fizz +description: Engineering agent. +avatar: app-avatar:pollies-12 +runtime: goose +model: anthropic:claude-sonnet-4 +--- +You are Fizz. +"#; + let zip = make_test_zip(&[("fizz.md", md)]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 1); + assert!(result.skipped.is_empty()); + assert_eq!(result.personas[0].display_name, "Fizz"); + assert_eq!( + result.personas[0].avatar_ref.as_deref(), + Some("app-avatar:pollies-12") + ); + assert_eq!(result.personas[0].source_file, "fizz.md"); +} + +#[test] +fn parse_zip_ignores_macos_resource_forks() { + let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None, None, &[]).unwrap(); + let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None, None, &[]).unwrap(); + let zip = make_test_zip(&[ + ("frank-costanza.persona.json", &j1), + ("jackie-chiles.persona.json", &j2), + ("__MACOSX/._frank-costanza.persona.json", b"\x00\x05\x16"), + ("__MACOSX/._jackie-chiles.persona.json", b"\x00\x05\x16"), + ]); + let result = parse_zip_personas(&zip).unwrap(); + assert_eq!(result.personas.len(), 2); + // macOS resource forks should be silently ignored, not skipped with errors + assert!(result.skipped.is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 95f58878b..69f48eafe 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -17,8 +17,6 @@ struct BuiltInPersona { runtime: Option<&'static str>, } -const FIZZ_AVATAR: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAgKADAAQAAAABAAAAgAAAAABIjgR3AABAAElEQVR4AUy8B5Rc13kmeF9+r3Lq6q6ujuhGo4FGziQIMIkUSUmkcpYt2ZKDdpw1s7P2zHjXO2fHnp0Ze894PLur9djyzpGtZEuiLEoEKZJgBBGI2Bmdqyvnejnc/W5BPmcLjeru1+/d8Of/+/9b3OQfXfF5keLFEUIJT/DF+TTgOY4GlHC4hj8Qnvr45uE75XAPIT6hNOC4IKAch79wFL/jSn8gSogkcAENcI3DP4zBrhM2Be6n1ON4NijBo2wEXGV3sqnxDL4JuIsNyVbEVtBfAt7YQ3iSjcQGZPcGBBNRLAm/czwbFl/3V45fA7ZgLCTAQ7gTo+FOTMYmxrLZojAgG4EN0v8V97DlsnFAByrw7DKbFONjM2xOtnA2LyHC/a2xP7Ix2R4p9dnq2QA/vxdTM0oyMt1fen+7bGe84IucIEpst5zPxmWrxwtX2Qg8Iwq7kcPiBDYtwZbYRTZQ//b+TOwR/Io3gREGzGGTCVgQu5ndeH9X7Bae+JSI/fv7j7D99wnS3zWHpzAre2EiNiIb5/6q7i+NjdF/gA3LfmZC018nfgJBcI1ttv+FN4rNCgGPxQcgfV8a+vdzfTH6pyF5CFt/OAzR3x62gCewHQzBYbW4jOuYF5zEP/CDsZyxEw+yL5G9c4zPIALf3wvh8DjjDO7EFTY+loMxOR8yjuscW5v44CB/s05Nn5GWzc7W3L8Vi2W7YC+8Q5owK/7qBYzP+Bki47Pb2TrY6tlEbId4Z+PjMm6/z/r+poL+lvFUf1d4nI3AVsimvD9Rf4L+j30aYgC2TzYBG5O9MEl/3P66+nMJfdKxuX9+L5sM02O57M7gPh8xR39wNhhbHojlMQlh/znfvb9wLObnt4H0jNZMM0BsRuu+0GBDCk8nIsG00n2+FMOfsDSVhzwxcrqUOHiGEi8QRB78ph7bBmMS22h/asyAOaBTbGls2EBMK3QkRBMyvdXkDK+/VQzCSMwogf+Yno3CcQKEl+NHw0QmdLMrgPwCqImBmC3oixAm6fOPUYPRlNkEEACCgXlxJ1sIu8ze8Z1RCH/oz9Kfq3+BzcleTJgZ0djy2SN9uWMGrH9//wJ+xF/YBvEIu97fGOMXrvWnALWxW8Y8Nh/EBt8Dhwoh3on1ljXOMl2+Gp6jvCRSlzGG3cb0HWoIQYEq4x+Gxp9AbY9y54fcZ0e6lVbr+XJC5JmNyms0xHMbBoet8lwQV7whsbbaTUclYS5urXe5LVuDYcAS+hRn42GwviAxbogvFoKYxA0oVOF5h62Qvfo0YHPjAr6wFEngRZE8OcF/8ajiWN7zy+7FbVIzGc9gsKGwff3BtrFaRgf2DyTANthwjIa4oc/5n4sDLmNX7N/PJ2NEZIrPXn2S9f+Kh7F6xsufqx3pCyZGYyLMxmC6hPs9sb8EJvl4kPEE71g60372e39cDOUQcX/c/Kz6+urdv2s0O7WeF0mdKU5/wo7tAf3Zw8wy9OUWU99fOrMbjA64MJtyJpJ0viRwoizwvsSRmssE1sEiOM6h4snM1j76V7Lz6X95Nrw31vvrdxrfqu4vcymoFMbu0wdvfaUCUbhA9Dix5ZLrTd6FvDKlZMRiNOgLDC5gYklgDJiM0984E8qmQ62m8atn5Sen3Jsl97urwUqLeQxG7b66s9UzGmC9jA73h8JwIAR0BesE2di3PiP6N7E9//yJ+3TCQvsDYsd9ckLJ4DlAcbYJyGpMDYZj9kSoqXfqXQiBxW+7w80gAYWUOReiisX3yc6GZfNAfdk7talwJln/80fcm2+Uk0f2rReq6+8s2csvmyt37Q/8RzU+CC7iZkyKuzE75mULuP/OcRGZn0m4iRCtWJIgChKjJ4Hlwb5kkaSU4GiiGybNEG0eC781FTvB80IsqGe8SkkegJnAyBxiGTYyG79PEE4UeaauPnwUIwOj/z8RjxES08PywIydH+E+fUCKRxQEFOGwIol8tNebTNJDGX+5HUBScE/QFx3s+D41sW68+kTHxP2B++/sKtbACMI2gCnuv3C7wH7BCvD6+XI8KkgKGQg7I1FjNtadS1rT0daotht1NkqXX6lXm4i2YJfKwoFF5fgS2fOa/2DXD0vEvT/7/XnYxpgiYDrh6fDC/NvrP3jxkmk5jY6xXjUwn0eb7s41Lf2cELhsbf3/99kPWYUXAI1AuZEomUwwH1u2eIR5Qj8kESkzs/uS/r87VcmJ9b9+5c2C3s6F7oZC7/P8jsCcpgRLfEBbDyx9zdvrcQq2jiUxghFe7E+HN1xjP+I6kz62BHYBM+NLlbjnZsUTe2M+/DdiJEnSdbPR81s2yYb5fISUDGh6n6n99/5AbBT2A4xHXxrZr0xF2G+YmS3h55S+/5f7m74/LVsd/ugQ6YHR2u8cWd4b9VOiw/lVYm2TwNDr5uJrr3fqzUAIGzQjk26Ev3NcM88oVx/hX/82/fCbwSmN8wSOhc5sS2wn1CV8ljS7d1/8/r3NWtNo6+69UhvzYCLXsfn118mhpzhGELYs/GfPIvRgS+07MI7fn3LHU1yh6jY9Te4bULgK5uI4Lq76I1FPoBE+kKaHhjUyfmfzH0rdpcWiRsS5jNA8p/z7YqtTc3+vEX2A41xMAL5iLqYAbI0gAn4Aefqyx1SQXSXwM1hELkoODKmUssUxCnqOyNNkWKgY3mScm05wRQN39sOxfwofMUBf+WHV7j9zf2hMwZwC2xXjDJuabZBNjrf7vzGdY3uSyK8eWPqNfddU2iW2TZsrgdvCY26g3b54S++YRAibpqNIhcnp+MDwBMeFAjlxkIqPeq/+vy39+7VDa72EQIkq9ONCxG+c6FZ3arvbHcPBV7WlO66nCJzlesSDZDkQdKgglgE56a+HqTUWgtXCFEdV/pOz3NBA6vpy0QiigsQECLvEf8QufkAaemNp8+2De4YPTz9VbW5f2/y/At73ZCdj/WSvpEfIhoYIil6UvVhZmoW7xm5BAFEUmItmpABtmVbgL/B7iGhBeTY05u75XKHtp6IsBnEM2zQsy+dhW1SRq5q0gZVDGwmxESiBk8zeYCDKe/BMfeoyHrBgqc89pngYnI2PLTD5699zn/jgBHwgz5ui+m9P3fmFzF8RA7lKgoPge3UiSHwoW76zY3c6isj3DCuZ4I6ezoRTA4TXfNMPrG0/CEJC6LdT659N37hsz7xQn363M9ZxQjLnJQXjIXJlcbO6vFPXHdozPdDW8BHIsCCIGz8pypro2/dFj2ki2ztbD+iAO0di3KFh1QucYr0X8KP4E9vpfdNO+bJFX1z4fqs9/+jMFxRBssxerclvFoNmi7jW2+VCZeYkDUtytP5yzIlUBg70+cpiZBEWDVMw0oCNHPnomG03ay91cx4nqBJ5epxrWGTHoD2HGWzqBXAaWFGx4VsekXiy1CJLXR7BRpcKx6ONsdVvbW7tBGJkLXXeGb8guDYYC4bcz1+YuPQJzvYMFtyfGGvBPff5wBObiNEY95X99c/sF2hzjAoOR10u6FFlkBM1zyeF5W0E3o5HM4PyodMDSlgNfAO7dLvtbs3qdTGjJ2JlwsKD0vD7wtwCl//z3iduOIe/HHn78uI71xZ2c3FisciOOp4HbQOJoSbCyBEZpgaix4SDyRFEin0xHvSXGwSVur5eW3l3YdNPHmfxCr6g9gEZCXWeyV2K+IbVkz3XdF1L5OOXLifnS24um+Pde526euVG4HqCbwfcEE2LpuDpFT6JqcAAzHKfAsxPD8WEg+L2rV64raa/dlz40KzcaNtdl5/MRaBloJUgQPYZ51sWrAGNydyFIbrUJU8MlJ+JFX589Y5r1K4tbPv+RenBX3YOfgqBGuMB2wkYwaS+b5Lwra92UHvskCkBTDXvicKT4/XfmL07m0kGQiaIP8K1XyZOgUgZImgQyNp6Sa81ZZlTQsLR86OCDOp7ntHxO00usKJRCZP5Diw35mnYLtmp5BW3/PvKn73oPnj71YUfvPQ2Qt10OsrZ3ShPrP5a+NwQfeRfKvmjEoNIWNLM1tinbp8BwCHADW61y79ZknM9/4b2kCuGEPjiPubdeH5QbY/5P3z3bnRhRZ55LpbKDHYsLxnNTyS9qDCwum4EQWt9l5NCnJ24YKQ/M+e853fWyqnPwZqLGJwN359R5vmGpyaSkcmtSi+Z/cBsCPY4pPrJpOL5LEaG8en0bOQjToCsj1oeRV6Wjwj5wsbDxls/eXPjzdsbrkd3u4EbmMJL/zUU3+NPP8zbOqh9fw7sDju8nxXjhz5j2GaxBF0IfWrv9r89cJGYti88RwSViGmq3+KsXSJqCOCoR0rzmzLsekCyQ5qoygEfQhJC9W1RFjk+BoeTUPhWteuYrgAtMSvNuqGFYyV3cuXmrW8/fwkujT//heLxz8s3vyU1FgXTcOJ54eGvhtJ7Zc9mXrEv1n0N6K+trwGQS0R9vz5rPzlmbm6Hh0PBjsQ78PEE0k8kQnf05LeKj59J+uKBiiuErs6/VtrpSuHmY5O7q2vbDx1p7ez6hqNRPzGYPzrPeb3Wa47tyF7LkaNwwn0WEKLJ5P3j5EuHFb2S3K9tJIZsn4axJiWkMTAp8KG1QRDYLt1uBwaybMLZPmMDJwkJc/O7L/zo8mLZdIJ2z271LCooccEO5l/gU3kay0PVON++b3buewlwgjGDKTisADF4eXaw+7W9P6ONa0H8cU6KEKdO/C51usQ1qd3gfIdoQ9GE2tkhrs8N7slRKY7t826TS2SI1UIAA6MNqseiXI+H8fNsD4pgdNrGxcLQC5eWTY9/6oH8vo8c+q456T/5Bx51sSmNF1WeytTpx4MgBRbFBILJPpNu5hRTko+Bz+c9r1vsGfpYb/6g1nyZzuo8YAhmmNtBnE9/5onDb5QL1VsbP7rhNWVz8sheZ7VAh+Li2NChLG/aWJk1vm+ImLe/rrde87hUwv3LxsRXmQnCtJjmRJb+1umQhPzOiubU3omBpqLmfN+DtfEcR5IkyD7mi4Ulr2EjQ8lFuZDMl3UKlzR/46re7Fiu73q+YZokcFTihBW+ce2HZP618PTp0KlPBvnjkCwgGNgkWzXjINswIg+bF/YPev/p8GsJUvAdk9NipP0S8WDZ65xXYy6ek6g8yAny8P6x0uq6JCvRwQHKyQRhu29xGlKwgLgGDWCBLYAPkahgQQOakCqv4O55826r0ehMjA989tzQkP5CUtL/mnwacQOmRobHiMgMIRP5PgMYE2DiEVtEFfLJSffJYbPWtTqFtSurd+dXtps9ReKCg6p3TTluE2kqVj+ufGu3M94x7jTt3YBzurYecbkPTH4wq9zm+chEcsrN+vOF1jurO4s7mwO8ptN9vtNxXIlIqsgS/b791UQKDAlyHo6momFtc3tnZGImcB2Iv+8FXR2hvwc+mS6HrC+hCvuH1XLLBSJXN7itll3aKG5UENf5IIIiEE0gokOsNqGm5d56SV98I7z3rPS+r/HpMd6zwHPmFvphrskLJ8eC/3BqNeMbXjdK5CEe5p72iLtNEC9zOpXjID1iI2o3tYiWP7DXKG+CK0xQfasPdlAih4hnkcADswQB4EjgeDxCBsPiXlkgd1e3qax88ZQVp4VeJ3U69LO2Fv4u99FQP9ztU50RHf/v8wE2VhG5B7L+L+5zj2aDWqP+6tXLt5sRY2ttu7ArS8ovfeBMKhX9g+v6Dk0cj74iVf5xKsjlM1/RvKZK727Xah3bEmRtNn9ia7eyW62Aw812p2cGVLJdLp5NHTBdWhw8p3SvwypSsD+jkbPDyOigemCDMpBJr65ttqqlRCZrGvjntM0AEQgsDwxTTOVHBxTHpZUuDBB3PBVoz37uz996HhYDdIXsYMyMSrR4rD44xVXm7XbXdYzu3Vfk6qb87O+TsbO8b2G7ffnn8mn+fz62lgmWAt8PjIYYzwSuqbfq9UqwWxVKlXCxIZj1+a9+OhlSIOj88MzYYhmsxjKBCgIug5lwEBuQUILTa6KqdLq0XHQ8ywFUUOgmb67beM3NpM9NNHF33ZBso3s+/XIzOvAqeTgEQWeCzxjAXhiM46ejwZf2eY/lLex8eX19ZWP1zXpmfvCZ98cT6ejrK1Ubzj8boc+N9l5v1vnWa5qqFHfhTbswayENKJxYKnX/5gcvImpcr9cEn7i2v1s3CC9pasSmkqzFeC0VK73OWUvCng//JmKGuTT9pSPwwcSxLAvRg9tbXb8XiUQHcyOAeLC8RtfrIb1nokFaFjWcwHG8ih6YLiJC39YGV3qkufYe79lh6IdEEomQ/qE/Fd7/+8rcE1Isbm/f5a0eZ3e8pUvR7DAZmoN8glGhEP/vTm/OhReDzi1KwrBQDgk//0L9tbdrb1w1X32nfP1OrVK119Yasc7NvWM2sTuwG/dWW8mhvKpFaXuN84x+nAqgVjRNWtnu1iqOY1NNprJMfrYSfuUOFkt/8Rw5OWLLQmD4Ec9zXNeclbsVJVfm8zLvQyNZCsYjoeW+MG3/5n49EnT+ccn6xop2txy83pzciRyCZn35RISazaKdUPPHgT3MDimbLXk2M+b0bobE1OZu8SevXVvaXuaV9q1bHdczel2j3OxZXafVtZwggM72jJ5j9nqtuu/okhxWQiMiQyN4stwhP73nPTbqOl4gy2I0GheU0L31tZH8BCdIPniIIBRGV+QMF+EzXaoGgOccINI81wbAKJiZC1+pTzxpLl6khXlhZK4++qCcOxgDSpibdUcOK3OPt77zP7kbSyTw7R/9m1g4YY2dc6j/6enK6YGtoFND9AinIGr+e+/pL72+sTZ/bTCiRzV3KsVZYm6bn/zm64XDM42Jw/t8Pmp3Dd+yiakTMUTtej90Rzbrdsodp2fHQjyUFT7GdPi7BWKY+lAmcnrEdANOFtyI1LODtBi0ve7Oh+j3arHBHp+5n3girtwTcceF1tdvSTfbyWqgIk0VadJWuTBAIkoM5uUc3zFe2I1oivzhGfuz092wNvxSN/Wdt+Z7FSkuya5omHWExvzyNuxDAATf8ng38Ji6eo7Ec7IkybLkO91OYSE9cUKY+ujvwAR5lN82yHSMJEJCJKwiPalVSrdX1rsOl4hnDdtvW1R3fJa4e8RyA93lKkYA0TJYcETWdWFd58VQWpl8QDn4jDh+So1lkHxCLbE3IfCk1Lh46GnS3TQ2loLASdUuh+ceVrLZP5x7N8qZgb3BlMsqdIorP3g9dvnNV37/0+RXvzjx4WemH35i78xw7433gqoZFq3Sqf2BR0KFlfLoVFoiXTCgb8HhS+Fze7budNu+IAJiZFHvdl361rt8Q3fO7JUfm7SaBlAHThXdnhf1oclek0c1ReKXteMqj4yMKUHNFi41Iit2FJ4OaA8wqLwWtB0KlwYkNWXujKvtlUawmz5+w0xkxOBQ2nl3/vbdtSvtJsyCXGqY0Yg2OBzcXfakWESKp/lIEpbd4SRPlhHPmBZcJAJxqkFDCXWNujD78d/FHoDtIMO91SB7E+JwUka82Wq3bqyXa4Y3mhtvWZyHMjEqPj51fQoMro6BAoqMAEFbVOGWunLFBjoSiNRTqI8f4NtZZAG9hpEGCEl8SY0qBx53y9etnU3JaWf1pY8/d+KR+JUAYY+xRq02hO3ildjf/ePyg2ObX/zlo1IkHYhx6vmJOO3WG9vNgdXNzrmpZlj111bMySml7wOQy2vQKmK2gK5YFu22PQ+STDjLEb51LXZtHbpPPnaUDkdZBAHB6hkIlC2fMQjWnssIzp3wcY+PigxTQ+SJN/gTUJyGRNL1+KYL+eXsPjj02TlxZkCY3zHLkRmfl+Y7asKt/eziNyPReqVmr+2QsfzQ3J7JrR23oEuh/ScHj1/wU2Ox+k6Xi/i5GaJGENUqHOl5PlAVuF9MygOrSisBKmK/Pkd/bY4bBJzn+7phNRu7lmXWq6WtYpnBDxw8MEHQCQfQtSk4AVVAFIdVQmpqLiwzCyEA12HcfnTPRPN+bME2xcPSeYoSGfj8f4nNHm6YpHL3ncTLf2DtXiOttzi3wQtesa785K0ebS197sN7iBwGjhU4FuebxCUPHyIxja7Wle+9BjKiwBTwTpfqVc7pcEbDrpXtbse1kEkxAoL8ikR+vBC7vJu2HcfxuUbHR/ABZAWFDURxNLAdu9eFh+1siXb3hH3VkeD/WB4GJcBeYSgAZRk+b8GAwLkIAuytJnMRTUYoIhMXwTTCsl4gfLM0NjV+WO/Zcog3QpHzDxx55Nie8Ux2eHLi5KmzKVfP2bUUwCnOUR1HVBJCJCNpUZIYRIAnZveQg58TbUqOxoKxCD0+KABc80jQhdPQ/a7Nz44PLZT9WCQkC8Swqe5SO2CoJytBMOyfhGWUPsUrTaXrCwg9kcH0VYJRoR9VALJgKTbMMS6AclLgCtEs//n/XP8/P7tTLvz+//HOrYX0//prMpVSXDh/6bqzsd06vseenuKo0fGMOu0WBWD7XvfqwtBOTbe7pRjvt9vw3ijFpggyEMcMei3g7Cx8EZARUkUKwP9IhMZCTqerO4EE+b3XoPtbZAQRHnM1UE2kCo4ohh3HcnuNI7Hrt8kFX4DQ95eLpJ2FyWzh2AXWzlhDCIJvqBqgeIReiCCgQjL1ayTeSmcDsperF7JjM1xM3WkV3CBQbSMZdIRExA5Ho40tr910Igm+WxOMJkgjpSb4xKFOfJjb9xFmJ251xNtdcb4avFdw2z3Hsp2mJUwfeuzBI3Oj2UElkt7VSdvlxuL8cASiwTks9uPyUd7l5VebmkP4Qwk/hN4KFsMxPRY5ZtCQ4gE4AjzACm1MvFjOJRFHzs4M/sL/HktmWrawuVYxOw6GI54TFTYiIantRhy9Rtyu6JVlBfCgV2kIL96NbW0XTo26J/ZrggiZxJgA+D1qdiAMoqIq4aiohYDCAiaKR4Fek/0pY1itoFaYDTsTCRfObLMBxJiZHthbpC8kAGzHu1ZT6Jb2WzcDQQaCxzQYBrNfhuprAwovmIppBgJuRZEFUeYltb8hdlHi3Hkn341f8AYPTacHNN85MLV/emyPH8/m9x+SB/KxZHQ4E83HtGxYDY9OCenhUDwV8JqZmPK7GwK1meaBnps9/i8Wxb9cEVbaHMogAnFl0R+bmEJm+drrry2tbyBCjSg8VATksuDb+3kUVPVAzEfciXRIk7gHBumZjB9XkGKyPYAHLMmAH2YJDuIlBvRjN8iQThwdmxyLUc9+8hCVMB4nQqXOH6jnkvq9Vh4BKMe3BFrnBZv4rR+/l1rZbgtm9dFZMjKMzgDP0P3e1rJdr6COYtnw9ApYCPHlJBlUqTXI5g4VveB3H3X/8GnyxdN0PEmhxCB9y+B0G8uAUGNG9O8g/OjBTE0231KpwZYNX4gUEqk3zwFnhNTf30v/T8y0QiUQEOE2JlvYG0d25As95ZCojW+4oaGJM1P5/XDfv/dI7Dn320f9t6zKbrPVI7yRUeu6aUmSqGiR2b2xU7lAHjwjihqgCAKsYzoSlB1hyxT/ctn/+Lg/pG8gSO1w0eVic2vhyoFj54/sGWnY/E4PK4PtAQBGaxZJqd5e1S874pYjfXzU+/QMv9NwV5r+f1+XUCToewJWBmCrZkvvGyYwRuA/uWfj6snwI9PxJ88ieGL+HTeo1Do2Wlkv57/9RjyTk+q90UZX3q3Sd1e03d3lhyfdo3uFOEqulmsZNnAKXkatj9hdx4HPh1QTZJtirYaRaCKdYeWJpn4qTE2HdAzS6LEpkPf3bGaI8CRMInoS+MCgrq72Ckmv0lFHEUSApkmFnk17R1L0rTJ3uy10XHYznoWGQ3dQE4EUYBes5wbVYJ6ESM2gndj4GdPzgUBhP83W8sXKxs3ehMCPior95PnKUqGyVRzHAGG/9Ztn1nYbfq3zVJNegMskcCYbAGAAM8OYJP3HRpyr12pW17tTrQTN1UxuHHHn7dUNzasX3Vh6eGowxPVcYvgU2gAZyarBbMo7k2OQIeBx2Gcka6yUwwxzn+rMAfRfzLSSmOSMy8ULH/NJ+EjQQYmx5RsdwjkyNT7+GLm6bRcaE//qb4A/+aZle67rOa0k13j6BDe5R+XDYdY9gIYRhA9hBWGErCBqgEMSOy3fQwJGuLjGjc5GymVFMsyQSmKEhAzODXwTgWS/ewC5ThAgLJSY5XMD17ZV0UgEtZ44oQDG5bgT6eBLcxxKII7rT0WCiyWpYCG4RWkWXWyKiDgesAhrBwL9scugzU1N5qWDxoZqSu+tLVaN5SvddjwSKvTMR2beKBAReV+nG+TUZTXiNxuha6ue7bu0fddV7qIgBtnhmi6H3gZ4nwHZNhvbCue88MY7pdruTrGW0qKNbunHK7cGY7wcTmQj0vjUFErZ2zoTJdifZ3P0cI6VxLAx5ps4+nTOW+3x79QlJCJgMKox4AR4AW6gASwjNzLeEhAPqk1SkKxWDNyagHI/p4RD3JOHi3/2N9vUt2CrUpI3GPFTGTKT5w7Naom0Rnyb42TbIbpBYlGbp6w7wXMC02AlPBAJagc5QoWp19TrVUSkzPnDa6NlChuFIQKCCxKDAy4LS1Q3kDm76ivRuFMqSXyGI6cz7p4osn90fCGYhsgzCqEvBS1ZntlxOzXftTl4YxaysrmwK4OffCw531z53s72UIdba3tWKIgAZ314olvveqpKf/oad+IwhpSalfFYjFvbKWmKU91d6qz9PgwohmCuCSWwIY2LWeWNwu71haWNra26Thu6kMpPh/Y9Ldz+B9wHSGfjxgua9yDNHoY1Y7ldgDWiVsi5rm87TOwjImrHgaURxEVWH/0H9RlRmDZQQIzDoWrYXQnkJGm8yRklhuSQKLwqwhOEt88cc6ajrm9Bx4WQIoRDgqrCKsvAt02E75S3HC4aFwNFoKqEMi/iroDXlZAvU76rYyK/Y6GvCikjVRVieyx154A92QRfEY05C1h4mB+4pXZLD0fCjm/bjpvsLkvDwWiU+/gM+Eh13agjFLSQxwU2lbMKrA0SWxgtBfoDDYTpAG9B1CPypYXWwJq3nBjcNy7s5enEdvHiSHz/0X3Jdm/lXgGWwouGE40ugcdKRgZlNbReVHv6qu6EJGbKYJlZkMJY+dFRk7t79flbdxrS5KOPPP3mtVtBPGSf/BVpYAQcyjavEaNp2vqt6y9qyeX49IVwejSu0raBEBzyQmtdb6dFETjDQBVMDph130thYGaIGAsA1/PyjFa3Njs02FbjMAzMqaDcT/kUwhI7UHktM3Pc45yy46u+05Boi+k5wE4hxKA3QUxz4jPjMbhiOCL0M2hoLMxgduBgXtz1c6PoA5FFMARFA2Yi2NMspgRNHdhxEtb6qgi1CGivYydEh4+qRmCFuoWEjxgxjDIfLFWhbpYM3O6AZWeTcljw1jqGqoWNHgq3kiiC98jc+ARvRMxrp/no4fyRqcGnDKNjWJXswo2x5FBazi+1V52ORDTP1M1uHYrrCoJh+QTYOuW1iBJVo6P9HAsqwPF7k3yyceund+bb7XZk38TDDx8uNs3OrhOOqAnZHjh8+mzigNTbffPuSr246rdWu7eL3NjJQ+dPD6YQz/j1nnev6qGmCtQPyTYEMy3TFrrGmLj1CcFxQBEfS9/4ZOhbjY11xDm5BCM6DU0G8plAzQt8lZfCvCyR1jucgrwJmpOivkY8DzYMakb4ECco/RgFpUcJFR7imahAEs/mgEVLiE9AXIilZzdWJoYtd4Dvdmmhyhkm1ViHLTVsVmBBKO9hffDBFDUlVBV00dxy5NhHcsZMPswEnedsR+9ZYpq8vLFqjsx8Bk0IQ/7Gwsby/PLlVnvME16xuPNqYnLQ/olZfeH9R746kc2Zhn5z/naxWWjqzdXFq2/w1zZQmBf8PQeFZsPotkw5JFRai9gMJDMQ3Ug8BasGU8ZecEhPpZt3f/a2ZxtqcoIOHQDk1O3qqppIqNxsxDudBhzHN9Xhqbnc4PjxRqNYLywYxduvvlw4ePhsNLPH8oCMBtUunDMfkoIJ1cPIVzoyVAfizygD2vDCg6E3oihZUtJp8EnhuDh5lGijqhqH0SLBAHHR+VNDokKtgLoyw/rR74ZmTrNLQWiC4oTl9MMYVqODFMPrGC2YfES34D3MvNGz+MCWJPwa4uCBJS8UcW2TTvf9U6MVlFu00hYARaAoaDhwBnAbCEpFwJzmK38c/dT/6PPx1bpvegbKZLKQd8xNoDKZpMIFd3eKlyIRgE1GqyWHcmjNoi1ueiJ5rtvuvbRx6erVdwrFat1ph2J0ccEAymRZHjxdaAipK9fRqQCU2XAgjCiXIn4wa4VuvQotQ+QlPDQY8NvvtpoNP763lHtfNJq/dPWHrXaz53mWJTSaUgcdxJQBQUeijhOJdbk0n5xDrUIvLV3faA6zgABdwXyPtT+gjQUlUw7lUdAEUVq/BsY8NrxZ2C0Sx5HjyczBp6S5L/MAkKwCtXWvV6a9Jc6q8C4QtZZrtB2T4Qi27fYMr9MLwH+4GNclrS6zaZBoQJ4wQnhnMRACdjCLcpBxZDGwIeA6pAorQAgPpQJLYG3DqjSY8uMR2uiiLQXFEUC8geNj9ZZvla/97HJoz7nc0Q8h0qX+ylQk1m6QkN6ef/uH5082oto7DYM3HBESTsVtWvi+LX1CSYznvPFv/PC7zbbdAVppCLF4wjDbaOSJZ5NhOeoiPfRWA87jR8aBgbhyV2q10PBuOl6/CQP9EzyXC5Mj3Nq1u+8Zcr489akgnPbd7lguuzs6ZeiaL8kQsXlDRvKVkmg94Koe14H6UrojDmYms3CNr3eErOQPoKtGEFYNOHZO89C1KsQkNHyQJvrAmB+Aywo0wLrhU4mH94tSlJYvucV3iL7VrVfajU6j7RXqZKdGNmuk1CHVDmmZpG2SngX8lYEcIBd6qIai3IEMQwChvIyAGJhDAAZO0GIX2Ql6pFinpooWUo3EVfaVCnHJMBJJBM1+JERCCqfIqJ3J48Nw7LaOViAO/EW+SEqNXrfFJ0SATLW/u36t1+OvbFRkKSrnZ1ulTq1UUEVrZ9XgpFtK7eZg4qidOPvqbRQGG722UjeC2fGBgxOZW/fq6kAweOysGomVu36wVZIV2xnar8IPxSwtuCmo4a6ikMpaKJlBHVJ4bKC7cf3VmiUVJ5+hkZQUeKNx8fDI6JU766gV4B/z0cyHIlolrzQUBWktF+gBQ7qFQG87gc1LJYereFJUhDfhjYDvuoz0EYG2YIr6XtgR1JPJ2mQ8yfVuOesv1Gq7WwV3YY3c3iadHgsruxa3XKPLTUbT//8LEg07hnpESCbnJrlchCDrBolBd2RV6ANCiBVWmbMFxQctcrvErTf/aYz+dywfkBJiDk0igzGwkGQidDDuJCGgiqhqHifHPfgsrafrPfQxW77gkIlWmH94f2yF23Jjx4rDj2rmi4flSyGOmy83RcEVM8NqbKQDHGRkMlm19XZY1tIHT+yZiRu1ZrpF/NPjWb3d5Pye7CJ3scjuGhpMNMCqnkPUHEpSMAjuUE58cMCN1d6bL1SL+Q96qQngZfDvcRkBgxDA7LKs5/4LRgaEZNQx0YxHxUHVm5Eau4WCIw7yKpgSoDCzg9iaJxne8/RuXc2gyMhICcxE4j478NIH3f/cvHH1pzfNK7fo9U2yUCbbHTIY5vamKLKnVIScDBFI6N0KIy5ewCfwA2wISJyLcQ+Mk0wICCAapEnP4V5epsUOvZ/ZQiHmhrhTY3SIkqEE2e1w7xVoA9ky/H9/HN0liM0gRhtwGQQ9fRiKoqy4N+PsGXIzaU9JJtJjexWJR1HMAEITzu3N+3ZrRW4XURmUlt+dSafyieJCzZhI8D2alKaeCmemRHRL2mUhe1YZPJqD9jjzC4UmwiXLoCVO1gbyVHYGzIpa1r2B7GAmXq1YamuHupbc2hUHJ4z6NfGR0NbF199t5857w4dlJANI+CgXkkB0H616ali1GXTCMvD7RGExoeArpHNAs85J135YuRMa+WKCtk1WNtPL4WmkOQhQ4ByhNxgKXEPt42up/zi48Q/PXyrfvGPf2qJ3KmSFEYKcyJOjQyAxOi8Rs+MC9/AUycbI65uM3yLrs0K8SIZj9BNHmRSDnBB2JFPfvEHLXS4JkY+RMFZjkTtl2rbJc4dIMkKGkmR6kPvpIrfdAirFWiYQ6oKLLCrtMxUj7HTY12KVjO3SvQO9icHF/FCcCq9cSx1S9lyImJ01XUioWUW8e0C+Ho22TdcuOHpLD7q24AQ6X3q7xjUToxecgS9Ggm/QSkt1u8D1z8zsEcymHRUf2DNya7PsdlthWUhoqi4rTVNXw2EtFPXCYVdqq0qMRAbFS6/8qKDu16bO7pX8GvoCedJCrYHzPRelUxu9ezAg/VyB4c8QR5zTm1I3U6U/FtqjpZSz0y4Ezl+Z8QNu5iQYy4IqwMIIBD2nzzhWa8tpJevK83/9wuZunRQ73EoDwsjy0pkMd2qULNfJRhPqSJ45QPLofxDIyACFhXxxmUeLDKg/FPNRURmKs7WB+jA+ryyRvUPkCw/SgRBrjwSjUcZumXS5SoB6zgwCPCeZGPl0hHz7Br/VhDaD+hRdoIyBfWYgMwA/8CCqGos10mAgnV1tVobr+oT6t7vRaTV8fDj+jte5++AB8viBZy4v37l861I0PGRbfLtcQ6oWuG9Y5Xej2YPpgWGu5Iaz5wz7vcye4cls74bW+8TcvqHWfMERNKdZa3YAMef5+bP76u/Mqw3k3npZ0OICmUKQKd5STrv5o3ogIf2MikHEaXdJEkAg8mYXiZSo9NFkEuIDKwBFaNLZaRpUbpul1u5y0bp025b5SweOD47Gu5XyCo3PoOafDHTdM1kcj00SwW8WF969V233WchQVQ5IRjLEPTNHLm+Rt9bpcAIVN/LKPfIbF2gILSmEfPQ4TcTITxbUdNj98gPuYBR+lcEMmsJY9eHjSAYgzKQFnNwC9bFaOpIm+/Ps+AZUEZmD4ZKoGnzyGP3Gu1LDBO8g+XDZDFQg/YZc/AbfAB7A0KFJZLfDRo529NbqW8rB1eO58amo8Rrid0Ri7CBY+lD+kccfOHt7p10L7m1u3kbnlwrwTotTxLBSLCUL+ycEu/fW67078zX+8tbm+Kiz6cRti8Zy7dnQVjTOoRCsIAuQw3pHBANaay8n9z0skqmHUkTvuigJyi5qBe0aH0d9zEVaYRktgyjhvg9kbdI8jha0NeMHGe3oo8e++PZK4c3FKzP5rESzcTVv9BrIL4CeA4HIOK0tq43TJrA/gO0rwoGlia+kq19fKSCOpJkIudfiZrIIbKjuc7/7JH6mANbKXZKKk2SY4tgmop3PpbyQak8PeAeGGJStIitgNg1egbLODEfZ6cY2KmJbh8lGiwaahwORt8cy9tGRIBulpgeyQg/QFhD8329K7Dl27JTJBIv8GSvwBOwl4ggPFTR07OIOsK3Xqp7i3zug9Rr1Rqvl1g3vzuI92zCTsSQQNN3Q45FwMjPCjx+JZLJT8kanw2nWVnnzdnis7vWqu66djMJ51qoVv9sIDabQ0c5XG9G7S+bspKp33HjUDATV8WqWxbUWXhaHOGvauvLSVkmb/ji6dlWhg/QsAtQK4ZEaJTTM4HbWLcKnuPZp+sPlxsVclO/J441Ss13ShuPcmtFu79weP/hYPDuiw2hQsmWrgRxBgxRKSCwxUEKb5/44M3g0e/kb2sp7lRrahPmH9wWjSfLEYTqYYEEkXvkkZBBNM5AoHhRBaP/PnsLBOGRbKLMAEBYDx4ZB79pikR6+tcvtbNd2qh3k9wghoR/oNoiqkXsl7daWeShvPTrLPC3wiBOj3rFR8dq2yGo4DM5BeRf+BSOxvl6WiCCpFj2VdxE0Z9OK9/DX2rHcd3/28oGBsUYrdHenOpa+XjH8lumvt25o8rGK0fb1ZiSey8WdTP0vusXCYPLIeH7gxJ65naL51tqPDN2oFKneC7//RE6VpZyCeIvKfrey25GDbiZXba55VgfZsMLJOCXpXe927nJODHTP+z/ZKPx93TtPhz7bM9tGp0FDsZgIwIaVIREc13Y2y1X/py9fFHm50bFgytddmypEjfmZ9ICd3DMgBT7v6b0Or1cdvS1H49B3bFUJ7Nr0Z8jkR5X6gvTOtx+78/yHHmjXutpmnVtp275lTGbdU3No5RKksAa8F2KO1gYSHqQtnAzAgSAVXc2t3XLbDt+2Tr97p3759j3TNqazNJkESsy+wEU0g243lZgaem9b2enoXzjlSQqVA/Khg+5CSTCY42EABNpTIBUwQKyKigBDUnVJjU9JA8cf0/c+646eu2buxMPVkbFkvuPKoW7T2gjHFJMal1eMbCI1qEhFzUuGxLTzatNc5UzuzIlTQ6kB3+601W2znpE1PRyDwmaiXHYsmkjEY4vFe3XdGI1kND5quG0UrVU1EteyBCcS3J0/2Wx2WiX8odaeTjU7AQr6msRTSzb56J6U9tiA9VYDHU1oAonc7MU8d3jd7x3ZdyRDuB/fa+XMoo0W18B9/768lBrKJruXbi4tLVxzus24oIbGDva0FAwHKxIEgCb55tBZ+YHII9rOlVJpdbMH6dVdAT027xUb8832Lz0jDCdDFN2lokK1IbROcloEWggYFtV5XpPmmxeu3i5dfOvW+VkymiapMMPUkN7DegwnsWNabpsXbztAubab8b+7aX/pdLfnkJkhf1/Wv7mLVvn7QRDLIhlmzWI+sIJyaqzw0L+WH/qg6Fk5QT8cXhmf25tLJGKSNbUnXjeUWmdXEkzfVfWukYggWIq65cXx2bmee6Xq+6Lvbu9s1mrVW0uLnY6VTKNzI5xP50Vl6PW7C/Vmdb2m9wK7wTewj+Swx/vICBm6BSODftf01hZXqpoZaefJA5Vi3Wh2Tb+3LfrS0laRD2fIwDRMUFYOelAERZnSMpIaPntwH3o+M7KCcI1OP3xk/zT8/1ahWGvruyTeI0rPBPKmcLVlHgVoWYWFgS0CFJoI6lNv//HVV16+u7LTaJXloDGUaPpuh3pCsR559ZqeS/kjeRzadUhkhAAoc7tEYgAZMevvbu77h1fql+8s//Zn9o+E6rB1r67Qv32XvLRIXl0ml5bJbouEJS6qBJWug7rtQj2K5riJJMoqiHPInSKSN3AAB3BY/IMPXwAXcKKGdZ5RGs2Nj84d1wL9icjtPF0u7m5dn5+/vjy/3dxFPy5wPCCHS8voujIMHR2w4qlx9UNzKTBmY7da2LLm13bNgN+/78jJ42efOPP+R46cP3tgajw3eGj/XKll3VrdckwURAFXWJGo32oTJAowQ06vITz37NP1CkoMA0dPnCmXrr19x94tlDpGaSa7r9B+Y8PPBrlTRj/j94hIlfSQsbXU6R6YOyx211KCs1S1RpPJsezElhPqGHpvYM5JjU7EIWuiOntObm8pvSJNT8C6A0pW/E7y27/y1g+/X3DU9x/xnjpEPvFE6H1HaE61CNVhnVtG4pXLrXwqmBjXWK8V6xl3oQeor+yWuG+/Gnrn5uJvfO7MqenIrYX1P/kJubkrj4/nD8yMTYzmJC1yecV+Z9U9mCeHR+huG/FmcLUcGY+ZaCoF5a9sijh4cHSE35ePonA9FAU6JFLLGEmgNURMjE6PnnosxLkjnTffu3nr1vz8Yqm4U2s12t16OZieFopNv9Hi0HyvI3QNjL1ZgHzoVzYOzz51YubIhQfO79uzJxpCizMp1Zythm9b1o0SUhPu2QePnz185NZaZROCIPJaCLUgWTcBICJNdYTPfvwMmhcNQTk2uwsQ/Noi+mjGPvbQ+7Z2b69W1yxtTzp/MCSLaPdk/QGcOOy+YejXbL2ZC712eX5xu+uFNa3Zrg4n/JmJmaOjmbovWc1Ku14NTRziMnvgDIReLUjkPFkbfvN/u/Kdv33g078+PrNnv3rn8YdiA8ORv/nH7jcu0dUqieA8W8yv9dQ3rrcfmAulBhTfgtazgxBAsn92TfvvF0sTKfPzpzvb60t/+EMixQZ/4SMXTh+e6Rp2rdkNqcqxA6MIla+vNp+cQ5MA3agL0VA44GI/XRDrbRz645KAI1xLkXm9owNSwz0yIDrfzcUlQW84ejc+e7pWW1i9+165Gf7YI0c0IbS26d5rowuWHVS5s4rYhI+EolpucM9Y9tj+MydnH4SVr1RrGzWjZMjIRWoGOkiE4RhfdbSqHzEdf7NuDyaUjz1y0lAGl9oWh2YndBSKAqsJhGPC408PVRruVmnbDuroKK40RTW97/y+8b+//Lc9W3KMMqFWMn8SiKYQ9GKck3SXJhL5WvXGtcVGNOL37F6vaWGWXGrgF88/iI9dqVhCUrA6uolEHCVULjUWoPMEPdVAsf7rP5f3nPi1P/zzmHHvZOrddD72hT+uf/0idaWsEBneaIdrrXYmDBiOq5T1R45AawzWjYsTYg3zuy+7CxuNJw4A2Xd+fIsstNL/7IsfOn7s2K2VyhtvXb53b3P+XqVcrT94fLbrKo5Rf3KO7DY5U7dbne5ana/hnAcDWTjTdiVF0FEHgB1CcQW8RSkVJtJGj1crd/7jqmxTgPDhbDq53m271QbXiWWSWng47LWthBiOP3xi7ivPfeTJQweQb7Z7bqvbLVpKROOAfcGvyLyHQ6We5693RYTzU3H02gptw4vw5umx9MVryz5auFHbiA5H9z2Elk++01BDUvgDD508PfJYTEBjnCeZ9Rjnj4Zm2nWLmIoaHmHlN68ZLv6Xx0ILezOTU5nZbi+Sj8zElJDrJUJyGoj52mbhu2+8slJ384oXF4IoH+xR8fEBPutiy81ImWH7jW+sbrRPfeq379V7L7/6fCJBv/4T44dveh998tS/+JVnP/T4qXQyttqMXt+yE1Hp5Vv+zRt1fIwOQ7RdvVToLe/YMRWeFrgFfXeLf/b95x+98NDwxL7/8Kd/+kd/9L+g5xtBk2maL7xy5cTcZMFMjWTJdJZ1ryJ7SISYAQStEVzBm6CKYNkB4gok1UCkYQiQWIiygk5AEbgiH4ulpseTSq0rNdB8qWiTMYoTg/ui0pMnxv7k1z7/Ox9+epjrrhbaDR9BmwQQpmd5O1UDRygQLQ+j+03mN7rKgEamkmi3plnVGtCCngeI0NDDIdcHlhhWI9lQdozlLpeXuvmw9Fvnzi9u19e2zHhK3G1u/fu//05EMERZzu59aurQh1CZQ5ug3d7+x5d/UsQHLBj68loFrSFHTtq9IlzJDs4y7e6Wn/df/9jAmRwq3Qy8xEdY+EijYdlRRySBvn3tojw86E+cfLdqJJNaKJP55k82L5w68LmPfWAwl//Oi9d6VtBpNhoBQTDrUuGN286RQwbAeo2HoFGcecB584jKsP5QPPv0Yw+s31u7tlw9cuZBh1MsMcwBilHVdkffLFRnZg/Xuq9O5Uh4VUB/OAxHXEJzio9qDFLiwZSXjos20gdKig0PMDWQWBAmPTSKAnzAZ+WoUq7sPrn37ADdLLcXW5686drD00d+6cgHNpdvLQRaJnuABPCcKHyYyA/QRqWqCEwVTejgDLXty3sSyCWDatOu6UEMfbienUilKr1dHkiihnqVZYm7jbvfEZxN8fghv1LuLBZ+fKe0tVpz612xa5JypxBCNTwiNGtv3bsujM08KYTjG8b0ztJyodB4+tw4+jwvLtVKutzkeK/bG9BU0mlK+amOFEXpLd3/wKeig9iVS2M2Qag2qnp1Jzn6IFUjPdTGI0NNv1XriJ//zMMPXHjsxxff+Bf//GvPPvvchSef9SyzY7HO3jdW1Py7s5s7nV67UG9DTKWYzEorqkZmpsYPzs58+4cv/j9/9deXbixGohGVw7E1v9YyINTQxYdPzwqSOD5AcbZnoy1//DA7XPX6GrnXRKLOX113kJSxD+jBZzRQAZ8u07CV/ODIsSc+nwypOWMFkfV4tNPo3mk7dn6w4NfFB858aW8id2/+6mJb08y6hI/pEDlNFZtNlGwjOcEANtTuNXcseTLJ6U5vtcnXuy5OMo2l4HX0YpeM5/hi+daAd7cpqUdnygDet2roD0ixk1SZbPfyxrZuSlvVYHQQPl7pWFlftzKRgdLGrV77Xjw7l0ocjuRP7w8fHlr67pF9yWVfyjQgLm4wnE5wmxCJwcPno+m0KYdhCIHloSsTZ/SR+KDlz6qtBT/+T7UeHy0t1v7bL5Z6YsAXnbNiNh09feo46gff+/6POCXWDUScqkH7DhoIxwdiCA+/d7FQayOgBbQHuJTWTb7QIoMDwf6pXCyZRmJVbdmVS2+wBqk+7o3oHqCP65hhVYxoUq/jAnMIR0NR1Y2FgoEakauwVKy7khkmVsGEwPuoXmqJzImv/snIiYcC39KFjODdCpF10VPcwD42Pnpy/7mwrVyZX0cQ6KMJBiUcLx7VYBt7ru8nVLPaRNuXWeooAzH16g7MFhruTNdyxgeUCO8D+IqyGkpwfXkt5JcHhvkO62DC8So5wFG2UzPDN7aXL17mHsjtH0pU5VApJ1j1du3k2PTJyb3f2RxO7T2azU6i4zyWmdWsK46m/+Dqlfo27/qZnpgPKcB+WM9qdGA0hYZ4hBroB7FxooHORf2KI+wGil29d++lb6ErpLZp3lsrWg7JDMv2h4YzSXYCx/bFxXtbX/7134zmp4FmgX8tw8m7iGG8TMjfm4WVIAtVgKDSRlN4eZ4eHgtSimF0m4PpGPA+DzWH+xXnvoFHn1IqFhnNyui8vdcRm4aA9r2pAV/TSKqI8hIqgIDggFr1i6jIhIFFyOgWDGcGBuK0k3AXD6jWpvvDaGjPbPZItX5Z1vbXS52CbB0cGbpXt6MaHxKFeCyhCV2jXUZWsVzEsQm05qlTWa3SAoCMZNuLybBIKo4ubbZdmNB8UjYse1RN+aGZpn0XlU5sR9E4V1DEkBjpGTi7o8SjsZTnFhstmccHMMhtGn2naEQ0LRQeLtfKifHpsKpJUtgMnT096b2zO3/bUoek8FTM2gV7FWUgNZRIpZHzoOdyY2sRh3IqYDLOoDNoKYvWH1SjdIHLpcPtQNvBoUODHt0fQbYAN4v8yNCd1sI8qI8XjuMD0dxt+mcm6ME8d2CYDhaA3Uu67dzc9rfrJJUvWA49dfTg6GDIMowdHAVkjSfcIw/OTXArXnIi7a/hWNZWPdx1vSf2u4MARICmIAqH6DM4ro/J4YnA5fFZLCj8J1OJgZzqFWONv9HCh1sde6GwtiQ3SHRu986aNjR1eCxLfZMLo2jUCUuB09ts+na5B4SKD0sUkZsWDss+Z5rW/kHwWFY5AWdp4OwPDaE7jY9ExMWN9TCkkotudyIS3ys3AkTYIsUnMUhRPtRc33I3N4ADIoF0cI6s00mEpAyOZVhGpVG822jdSw0faJauDPiVMeXu0ZHDa9UFx9lNScvEWat2HBWHvdJx2zcHQrxibnutNzZ1OR2PmUSNK6IjRjpodNi8jg/T+JU/+07yw7/l8dqk/ercnhAfOZFNRi+9/tba2g7gpvu9jAmNR43FtINPnOXOHaJzk+TwBNkoE3y2CTKpE+Pc0fFOV9ibH91jdOq1jWX2kRQE9UhuMtwZG5DOnT91LHoJPVUv3ImvV/UvP4RPOgDkQ15bFQr3Px0FeARr0PJxEkJCIiBJD37uf5icO4GWgMr6O6+/u7nV7DZarpY4xJtVT5SnxsaGEyGcONqTkFW/jXNeOjsMSjZbrCNmOC0OpxLoNbyzWYO13G6gR8oNCT4qE/jq2X7VCaUi/Pd+8A8bO9Vqs1tv98KaW6ohN0X7kizsPRgbiqVWd5z1XROH+2NpNxQh64tBq17rGGUcZuZFTwvFJvc/mtTSOL7r6c3h1EjTu4sCQbcnCQJgPb/TojjYiDPGo5FifeFFs+UsFkpu83Kg5iYHhhVJCB96pHHn1ebK4tAjnwqPz1rtmrR56ZNnjJ3uaH78kOO5d959kxzWOwAAQABJREFUDTTSLTI4nPnU+eHt7RoCuF9+Rpk7MhxPhwfSAhR5fhOhCx2MBodGPdEvOuETJ06dWdws1Urbc0N0IgnXrR45PveRA2tZpfTq+sT336qfmrQ+fpLuNtAlxr0wL7ZMgEYwQOzUPxwG3A8M0vDskff/wm9mVa5Ho6ZjbZuRQ1klN3i0WWuEIzARiXAshV5EOF408W3WOmGZJiSvjv4X3hmOa6lQaGm7fmOr0UCDrs2nVH8mqxY7ftkQcaTFts1D45mr165/67W3iq0uGGD0qBAO2g0eh89x3E741/8KDYDc2naqwWsukD4RsaOzVtdsF02Nkkpl2J14dvzhieCJtN1uL8jCwEQ6jw6PlU0uIuVxSqJrkXR0QhEjJDTgK+mo4A1IsfVK0eCHctPnRBzbZGeIREvv9q7+1BDD0w88GbGqA87a6bGCaTZp/JE9Y4O7G3dLO6VklER4G+WgQtU8OUm/8OkpZWQfTvPAS45l/PWt7npFLreDmWEyl0M6U5CU8Nkzp6b2jAwNyEf2Sp97XHzu/AQO7bw+L379p4ZpNX7vgzyAF90hOCD2wrzUz7igLqy0wD54RJIERT754V+cPnYO9T/066A+MJjdwzf1UrGRHBrSIgmLyBF0e7TL6w0L1ItqooU2HvR28O6BoVinbV3dqBXbroFz+EBOxCATRd+K33GCtEZBw3QylRT1v/ju3+9aJD/JnTnmWkj/cJxdl9HsJYUCYXguVGlxu6W0E80ESjzBtxAeVMlwSFZUlFpmz089+on83sPDdHVtaenuzr3rtys3FzfaTvHanfLWdhVlD9vmnLbkULtV2xDMaw8Nj9SaegPqh/CfqGp2HIZS8B06MN698YPq/HunLjweig/oW+8dPxBNcluNZl0K7z9w/EKxXI7zrb0jimmQYtX44vvVyWPHF7dgunl8xp4Y9EYy3p1lz/SUpd1g36S2d8INe3ej9M7BocK5qfrxaXco5HaatTfu+v/tZXpve+crT3IjYYTUqKPRWzvk0j3IO4sR+vmYD9kH9fP7D7/vF34rLeFQGM7GV0PWHZw8Wl9YSmfUZGYIVYOZQY3vVZZ1DYY0RHuDMYpC9EBExdnju/d2LAeJqJTU0IuLEAgZDMoMYtNVx5N8F+Ubzzk2nbux+OLiWqHDZUYngwNjRs/g2i1+IAd3oJpwvvxgpKEnJBqpWf5+DWiijVaNeiMUcswkPplhYGRo+uBQdqLpyG/PL3Yb1Wsldzw/MpnQrm90NjwsXDI66K1BT4QeRQerIN/c8VzOu1P3YuheiWaGxmejEpdAkTIyhINZzu2XNjfWxNkLlTuvnz+ekYydKL/oubWYZpw7mnz0qAF2/vTtyr6h4POfnf3Dbyp/+o3Vbz6//fghNBWEk6nQ5IB79S6yG3VxC58XRPAJcaJTR3Kvo5+yE9y6p/3gevLvXu0Wa1tfeh+XV1lDEcKSls596xpfRdcYjhYxQBoV4UBV0KwhfPCr/+b40eNBu9QSomH7ZnXj5cX3KoMJmhsbRaiohSKcY7Q9xZaTGUlX1NDscCzCuUv3FlfqerGn1gFFGAHA1JDUG0mGLAfN83ImLCm8H9eEqXx2e33ztRuX0BBVr7m+6ZRLXqkRxGOo8SE3Rqc7FWv14Jlz7r7hwkph6lQiuLoTuWRlrbQaKwGol8KJdFolCze+vXfqyKHzH4wvqzW+MzE1mBR6KISy5q843+lshQcSqX3HYgOpE0OJdq+VE5zt/4+l9w629L7P+95+3vf0fm5vu3fv3Yot2EUlCZAAwV6lSLa6LceyZ5I4zjjOP844k8Qz8YydmSTjeCJNIjuW6EgWJVmkKFIkCIDoBLDYvre303tvb8vnd+GdxWL37t1zzvsr3/o8z3da1tNnzl59Og4KX5UyklNxhotf+Z32o585j14//tN/ftIc/Oyntz9/zZKDC8nQWBr+dVwZ/PRQ/rOfNDzH+e1vJpc3MguvbvXqpV9+WlmeyQDOBbP+xMXYP/1t93/9zuikrv/bH0jfe1ObSwUsM+jroSZAhdqk1dzOJae/9Hk5pYIaFuUewvIHRZl+PT0Ogiu6xyIKRaBC0l78pd/ZfOal3ngijzpqIDHwk9vb0bjcWV451/QSukEu5E2M7NCuuYYRC2mrCavbbN49OmoOAp4rA7TSAxSz/bFPGz1I56mBAA4oO8gTI3sxa2nO6Pd+/E40JZBRfTVW7umJQWU+1wUy2mgKzTkpFFNXroZNAxhgs96LVafRo1KjA7kM6ow9jMUTgVjG8/Llve8nU5tZoOK1nYZknp9LhRz/oxMgGvIEGO2gH7GM7MWno6HRKt3z6Xand9SsVUKhkKd5ZmTGoqShjOrEE2YofvnF/sl9++FrgUmvUkWErxLwG9Dzq53gH/1I+t0/rRyX2r/0vP6rv3oRTMwLl42vXXO/+ULECgdFS5IizmScTWrPbXq6PynVYaDb+bpzWBnvF9qFck31Os+cdV/YkEEcYOUF1MWGmiH98W2Ov3C/oh8gMNYazbHsxuUXfuXvx4OAQzqT8rsd16jme72ddy9d22wZm3afT9VTo3PKsArJPaCbOakJsfBhg7KKFAs64JcAyo8hGsWd5RQtbR0rE9C0tAUnxyMPPzcf/903777a8taJSqfSzAK9FaE7s5Cc1KpmC/KDSQHLhQsuf/zYoeXnRbzrK8OtBqoy/c+dqd51RiNHbXz8V/XDItok777zHXnzubO+3I8ms9lsoCZ5waih6msJ/6QXJONMmWrUb0ZsbF+xOx7Q88kXHo5VObdwnWwT8oDlG+ciGmgt/b/6/Tt/8L+M3v2zST/we68vGKYoxx+Vuod7gIGmv3BL+q1vJg3NcZplZJXOLhqwWdxh24Gj2etTcYOiD2TxDz+U/+EXBFLjwdGYjhgFItrCgFZYiYDmZ5LS1JZAwVC5+9Ejaavi0VjAUNAWoEsszL/ndzrH//f/8Gvp1cufvvXEWsJ9tNXq7BycAaWl57z6Hpl1OGTYU7DAxKyK1jk80ECwcomGsZiyHNeoV7CLsbCWjhqtoQGzcSGJeBFx54RC0/LM3Hdff+Nhvp6cDqNJKTfrr613Ptxv7Pt+t+4BbH32Mqz7USI4USOzOrJFjqy/cln96kbxuDYJJ9xYrNEaeCirgcySlXC/WeIIJKyiNG5tpg+Oj/Z648px/eCJaOvJ9MFu1R7YesY8HHR2zsbWKgVupEdLyrGDUOTDsfhnFmK1w/vDdmMhzH4bdMqWbnw2/uQrRNbvvPGzYb80ahacfmM1637+Cenrz6mLswCcRwgEqd7oFJ7kcHCmwyFMShXO6sD+vR+7n7lmfe6atTyjzISdlay8OiMvprnTAm7EZnSHcr4mgItv7kr/8R4QaJI7UQflOYWKAfFPBIKhYsZCntMv1kors6Gj/WFMas2vX9J0azCaBOc3lNii16lOTu5QqVaDtG9MUPhwbVZSaC8qrSGAJW0lZYZ0KATyYpyEwj+BTqaql1bnf/DTv3p957bs6o2Jt2gMzp7rdAZuvigdN7xi27h0Tnlyo+H7vWqzr6Yv0LdTVQ1iy3CvNFSDoZeusp3OScUddCaIa9qIPY2Vrzz39Gq623cMdFUe7rWLg3w2Pq10e3iKYmc4tZWQmqyP9Hqr/d5+b+A7zR4sazsaMp89s3g2EwWwEk0kH1aHcKGQezH7x35ifu6pLxweHG0G7/zyk96ZGenqGflTTyi5rE50BvWGEsWP71KC9sHSyvDy7SkarW8/9P7sI/3ZS8bXrkOWhpk0FXGzIUeCYmVhp4KIptTR6ote8c92pD+9J0wQf8VPkNWsVMDyY1ktPhuMxMnCQe716Og6bsxptZ584cVwJDpwTdj3htOrForh0eHCYiw6kz2fmgKGSge1czHNEErMgSCgQWCYsJamWtzSYGN3J3LS0s6vLfz16z/8o/ff7fYD0ZS7FK9EVa0xct570LuzBUvNW11RVhaUXJC20OLG/C0NlhbqHOhBPziZgHgPRLxwiGeA1OCFoxFwbn2YJUZCG7Wz6QtPX5kn/3CX9lJJM999vFuq+LVMympPI7GIkcrkluf1cj4UWA4MbxcBsoyX1p+4sLx4fwDXReuZCWN2fto9NvoHreJHFW8jtnzzK//FP/mLf96uv/bnf+MZaSkjmyalGV1g/5E7lUbJkP+jezRvidGd7gAKmzafUX/zs9O4PiaS4zi3Wi56iCx6E4nmMkG8NJjIpaYMJ/JuSfrxNmhqXktgqrH+wYgUyynBhMHvNc2ZEhCOtOlkjBTgQX/v5S+8aCTmqVxOWVJHLhw8WMsF/eQcZBaAKy07sRAVMiUjV95vQhebDFwlbvk9Kr2CRqCPp9NEPDoTlP/6jb/8wb37qgIbKriW6smqXTkZX1kG2pWbjc+eWUgtZHKenDrphoIR9fKMo1V2xkaIdM+jfqfGVcr++WIQ1ueg5YWTKU2zQ6Fx2Eh/7872G8cHz8+ewYVZqnFt9VyhXh93ji0OFxjOqVasVeSJvTXZy649eW1u5n4xT6adjUSPnbATCOoEXID6ibXH26XD+422u9N4F6LJpRuf+8Z/+y/+/T9T/uCDP/8dCxcrhaLTVNLGMxFeXF9SLmShi8nDKRBpNxxwaVJSfWOB2JZuD9qXPxwRcvjbBRD3ghlAjf9hUXptVzpoilzXBUkJSJvGSFbmgQ2LgETpd0YDtNAcRY8HnDE1TcVMcXen9Xc+CEBPoBEoTzfOr0bi4XKbTofvGImcXBs0x+F4itrnuYxWbnsB3wG8gC5IIshF12czmfzJznc++PGb95uTIc9rQfAkbF7ILq2nU6FkygwHlpaUt/KBhzvjVhf3av3CE+P/63tv4LkMXAcbCmIiRZo31tpdzZ3a067qUP7SHENEq1Nf1x3QgpHofr7x4KB4r1iZybUR8SiPGwHIDc3DcCQQbJfUiH8m2K/06+QTEPJI+alhhdSQpji5oDPW3JPQ5WmgFoxBHFVn586A245HQr/5P/+rj1//5r/8/j97xnx8fZWjqSTiClg+SgaTUwWICHVtX5qw1kPRIwPrQHWt1/c7A7/Vlzp9qdqFT0gZVfr5ibRT81Fo4/tZ+ijQlRkUSzTw2VSRh+0JHGMVnXJDiacCKoFkLE56PAhq7775XhDSL6hkVQ3HYpNOMxBLX9hciuhar3tQRtmVpqQQb9Pq3RHSzdBFcDa5WGh9cXbQPPg3f/L/3T8qpOYRoFAKBcQ5+pcWFtTg136aj+p2r3FsnEt6x0271e1Y8qQxkH/zGWf74OFJ/ZEaXcKcid6CbvmhaNzTkzEKaq5cb6mgGHy6W30pFY0i9bKWyn77yXOSmthtdTPZYjI1KAxmfXhUwRjHOxqNzC9yfuWX18+M+pNSv0ZZnFJlbTjI5iAsDWt+cDipSbW3U26VUulQaPsNgrF0xLSCkj1z7lL4ypc/7s/f/uCjNiZz4g376KRhbglCaM+gl6zA4cOUA7xtdKT9POUKHw46a/2oKO/XpbcPpTd2/EKLjiOnXIrEpZllZXZV0wwiUky1M2gL5U4rbrG1IYioxFYtZ9zpAYHx/K7bbxmOBigPOpTdrvSanf7J9u7HH+ztF2fWN2OpjACnapPqYEhTKB5CP9Ccj4cUb/Sj9976/sfvHbbbMS28mVtsDdTm0InNpL7yqa/frdPe6Hc8Y8ZCcd6vTpSNhG0a2vPrpjcpfP+9t4ZOkf6EHUwa4ZgJYwq0xfMbX4R/isOLBOmLyFpy1CpPwvO5aqE2dXvv7R0Va2YqOgxY+klFUidOUoEqJ3uQpqMpLW7iuL7zbn4tqbU6+gCvNy5SEL52+VkiOc6uMunlD3ebCBArgcPaND09stefyJjZYX+E8vvy/EziudCffDT6H38sL0ekJxf9xYRzeYmnhY4Br1lwYolkqCtVQZbXBRj9GLh1T9quEnpDqiYHImCUEmliXy0URcUA8TeJkBgeONlvLP0Jzt7pN1l3+PGi8O0rhtDLand1dplXH/FenEVr0imWuwN0DUKHBa7p3/w7v9FFtM13FiN4EGXUP3nruFbv1/fqlaOediUX//St5N3jk/KIsoeZSqvfePpTh+PFmDWG2h+OaTndLbS9M5FBpc/j6Ovp0b/60w8qlYPVVVebgsexpi7+11ODsdhAXrxbS0Nr+/rVQLE9bfT7VzeBDgzTK3oqVb9TutNuzY8Afmozox7P4f/KrWF5pLz2kHCAuHgxEh6Zi+dgzR+3j5skXpHAxuXnAb6dHD4KzEW0yFo4e6EjFWnEASSfWbk0O7M4JMAZD4LY0dGB3vjD9QXzPR3xQekPPxJw5VxQyoalsElTCX8rgVmlsgaGpQapZipuA6bmkx+gvxJZeW7RiMRlcot+3xk13dHABdxshrTkLEYbeL/dLk/ViRBqimJaw4ZuhYBmob3Y7Q/QqqOyDUYBCO5edULplGbySJO2Hj7+7h//x/QM0r3BaBIVIy+T6G73pEgYUmt0eGJrZ/q+Ve0IWbJiwzVeuvVULnfRbg6Rti12fTS6/uMRpZgxWUXfVv/Op9TXP75/WNmxodj3gAXmGMjg4Xlk62k19jJiJRnV3gfna+vzUfXVPW8sx6dO5IPeiqGuJPWDg0bl+llvNjmiX0ov4omZIZWWSh9obKzcuu/KvbQJBbFzWBuYOgFVIj2/ZLmVg50Pea85q+m37yb81sRuj/uo00FkkhbjeuX4XtsDXuQuGwczyW5v3K7VPPCJpIetDm0ACYz/blV6VJLuF8mqJMHFnJzGNhx5fGxMWjijrV3UL14xgyF1PJnQFWpUnXaD+qY6vxqIJaEuOK2iN6pIERjJoYCokFuGrnqTYb/T7dqjPlQyTzLAohMSFrsOMSUC6RQWsH6joZvJoL35dDBsRSOh82nZmZpPLMxemjl7ee5MNpeLBov9YfXgkPzAv7K8FEt+6Y/voNI3elCmPugd1N2p665GHYzPS5vGoLX1l++8PxhXuHDjMfIOJEaa4WrerXPrjoU8ynA+4pjIVjYmYUV+ciX0mL5YyJlD39QJRII3QqH3HxbscIAMaRpPTv6yJJ2UqFeSLzpPLCyX2nYi0DyoE8QEwsFxsbozfL8ReuImFeWIqTfKD06OurXOyDRkAArBYbXXqrjOEk8OSg0G8IN7H9+4evk3v80Svf/qG04DFo0vETBzPVDEBGABvxc7QUbLuTctPz0jZeflWBzrbAaDQmUMmipwEX4OBnJ6Vs3M6PiSdsVtFr2gI8+DNACOAhY0oA1tOIEDcFREcXj7QG+MyCpTdYht8Du5EFgVwuKwq5okzyM0bmmjuHJUG+0OTD6SPulX2kMqNldymUjwKzuN/XelD9ORwNXVz//VrrMep8EtZxF3oO3v2GtR77jlzyQCK4nB//aTO41WxfFMChTRBDxhFBTC9MU4vx82qnu2dLkVm42GfUSlPq6Zn12TW41xOGT91lP6pFNt97Snnvo8EStkccDz1MTBSS7roPzpijpr0ej5bNqT28Muoq9oMw9NbKqQEC2d9GrAyW9tXAlHqhUlktPcn9dGM5nly5uXB/WtVqusqdHQ4+/vHLUfR/c3Vxd/4YvW9cuh736/9egh9TWIvx5AVoIXX/f0gLe0LG9sKqkMYEkBOu92/cc70yrkTd9vNSA7oDsurWwawaAEv6JZ9gZVKSbLGQCTsmAwCN6h5B1WwcIpVJz0Ux0EkrHmuA0jHioZDfdc3AoGwMAYbADJ0WTqvvmD1z71+U8RkaAmZU76IykwAV2qaeXeuNCWk8Gl//rrq/mu8nYewkSv3hakfsp+Pz+xs4b72CYw8z5/2fyrn9+tNE/gTDxzDakTbwwDKQyQygahHWh3jlWl4JlXGQeSCkBj9lGngxHnG2b5wWt7H712f/uwWKkcHxU63WG10kIajosTcPSlVPpsMpPVQ/SGMgHZcpWL6fjZsJr1vPPJ8ILhjQsntcPKYsCNj052d3c3lmhd0T11AlToer2ZUKDYGivl25vuXXM5/d79jwOmMTNz5tLGhYsbTjo3CQh2hRONaBfOmy+/FLzxVPDqdXVlmbBYP8o7R0W33XOKZZcEZTT0R0N5blnJzOmhsAZ/M7/n+S1pMaStz1rxMGKfcA/1bNz8+KhTGSKsZeESwfPiu1GJ9KMzVJ8IlehtzUb0RDiANBX5tjehsSj16mXkvnLZRCaqIBBNR6w9VmLUnT0ZJ0eEYTjsZ/KnBxr98ObU34jbJ3U7ZXqLUV8z9G9dMwuFrR++944ttdC6uHkVeVCXdoKcOIvMkx9Ky0m4FjKtuCfSsasxIxYOm/crE0c1bgZL/8/v/u9EH/TORTkLu0g/FeMo7C8Zm2JSWVeESeWi0PJIki2AUSUXDFvgNXVNRZ4N7MSZuez79/de+2ArOxO++fK5k36crjVq1ucXl7z60TVvCzcrrUze2zp6uPN4eXHm8tm1oOlM2KdAbPuILrxLr7Rc6+weAM2B7qs82PYRXojH5FZT4icpmKppK2sGG5YvTNFPqRYlcyifzwaWMmEACth0uDTcpI8O2nfy/YUsMqWYYKA1ImHuOtrUSMBs9AoPnMk0asrnF0CTmEPiK5oIaNpBBTW1Gy+9tDQ/Z5l+zVazAeY/qICx0XOLBWOdCVk5rU/rRw8mC3EZnFl76GNctmvOmaz+/Jnpv/6LH+Sr24mEG7dYV79asBtNpKrg2dpE2YQZaG6QKbxZ7GcDVvLmHDoVDq67YUVmc+mTchWrTy2Nxhu3nl8xz4ToIPumcHzBknQFBg4ZGIE5OK37iqCPvTrV+2KTAOFgO/nah3eLQE5e+tqzQys1M7eqOs2V6V27X3UtrbTfX10Ov/ORflgsD4fVaGiBakGltc8r1pvjaBSYEQAQNxSSS1XE48D8gP6UqmWBeDDD2txCgPNaroxHA0RwpEZJujmvJiLm4kwctxvWvHxrigL2UbW7nIu8eCl8bdX5g7dU8BkUS0lFpXHDDy7b6ZVYc5vtbPXGqIPThUbLnVkDPlWmySgeojRGDqPRPc3q9lRpH3adTICuBx/NMS0jJLdW0+bjsvqgOJ0Pudvk9ob65Iry3dfeL5eLZ9clS5Y+OgiiThWy/Nl5xPbiph4SylFhLfSLz70yllZsb7HaVUo9Lxo07pS94tBYS+n53QeTqbdwC5UF3+4K4t5pcfeTIFBcCtHrE3eDZRc/BT+JdIhMGNQH/8R2BsPJYDh0HFs3rVq5HoyZ8Wys3a3+ys2+XtkpFQb9gf1gv7h8MUFVfWu/kxC6Ou1Kq2VZzrc+y2G0721zZKhsiluYz1NwhgYkFQtCFNMMy8mkFgz5DAarFbxqQUILNh6Qb61aV9bnWAKqu6UaFQDl7kGzPXaeOht8+WrsyTWUN92tAp8YRU60LtBTHMuRjDTqmLJNA0ekcJgYmVvC4yKFY8/O5RILSyTPm7NWVGeIB7cqEA/N7LYs/kATuD4Nnc8qf72nJTSEPFXivK/diOaLW6++8yG6C0yZ6Q+k2bSxPk/+6s+kp2r2fChM4GyZiIt+9tKVkYu3j/mC/elxRw3N64IaiqaVxtZoPBrT1eMywhcQp5u1FWddGKXTHzzGqYES9IfTLflPX+d7ev2xHuLrZLPcZ2SwALS6CxfX1mesb68X9u80iMSQzLi7OyiOSlcuZg9P+uBnWWve6crZWDbqsR+iudizu0SlJxKC2AOhDqQA8M7llHBIRS+x07bLx37pBJizyBieP5+8sJS6sTkHZjaouRDwkiHtg/3WWtb/z18JfeapLEilaysD0rrtkoLYCGmbEB0NBBVIUiMxr4a7zlvwpOhoncZg9HKCwJEAtCSCbnMinbQj8/E0F8QytFXkMwLTmGVFDHchqd6cHV2Z959fIvgv/PnP3m43P6HLelA6v/C0vTbv7hxNj6uKFk4gVgX6RG237H/5vT9KRcLXLv5qKBjqj0bFtr05G8S4ElTOnL1ervylOrEUm7IKaTymRiwyfoE2nzBIFNhZflE4ECy4U0Ki2ABxLcS+KMl1kddU7hDKeLjZbrkWah//7W/NN3YenhxR5Efs03h80HNak6dujp+8HHuw03/4YBCNSukYRDwSVS0VdWgKNJpoFvuAxC5dipgBuoa2ZcoM+dndGdfLfq0kcYiTNDoN5UxGeCDUl97epSbQQxyz0saV+DeWlRtXc5ZpG+MOIsK/8zlksM13t/y9GngFJF+HM+QyI3UwcrESnH9IyPQjYlF0twP1JsyH7kw6HqWhqKXCci9pCQAktUEHKS2btJqjFhuWPv7hRx9lc3Iibj84gEfYJ0Id6b6Z8MgucM4gnOEDj4aE5zOqpbsjWx/26FOIGuGE1qbbL3WStaEosvemfr7taIk5tbE1HPXFCv+ng//Jwp7yuC1NjqKFoeoJdDvpifBXQsoExy0cg5DoUdpFZ9IFm8buwOaVCbeRdvgHX1d2HrZLZeox9lGxfed4WG54Rmi8sKitLmY8FT4Q+ltuOIIUnbe1Y39wj3EexvUrsc3zweV56/BY0DKHA+dkzy0VvHoZDThifCVpIbwnz4RktDjo4uqEG7ha2b93jFCO/dXr0vXLEXtQFz44EIALcGuhezY+mos5tHOrQ+3sDL1CIGk2Is5AwRJRdQFEn6Wx06xQv9dbv7T88KAdD4ELcjvDFoMr4KbhQ5DtbRO1Sfb/8Uc/uN8cROb6U7W7W0Z7MhQ3lNy89Ddf+aXPXL01Fz84qXYQVzm/hkuz1HbZHzQd3UIdwkEfd+p2L8zk4qlLDRRcHe9SRq/aRsdF1kIdFh4oBD6nRocyi9gI/sQZM1WNimfcFOV5qhpUkzEBfAPmGnvEJnBcVIYACFlJYVWxqIpWawyeXOkfHhEHQhO13t9v5yuTK8sx+Jt6yiZZdWzlb35pqVS1MykKvKFHB+71i9FzK+H+1DZ0++MH7SG9PNvb3/FqZa9eo46pCSwTh0HzLpybeXIzd3kh1rU5qzIjC3vD0aPSBH2Ibz+NJCWVhHUrFoYERasZUXUeJQuKNO0tRSebC5HBVDko9BHzGWAJPdRr9d2TNtCmBr+MJysXLqdAoyv0fuWDjpYLegjhULdFOXY9F/3xR6/fP6zC3VuYMZYyBCxwE5TzK6HPPvXiZy+dSRh6qdOZTV503GChWdGCM6ZdEkxo+kNJLzGQXdcOYVSzxs7sIoGdEQ+ZGcojCIBnV94/ybRabUHkBV0EGoPF5eWhElOmAUUUMhIJaGyu3ZqC/1CDOvq7HlwUGGD4OOrJQJFPAyN8MvlPb+j/6+/5ZmOrWJkuLDC8Q4ZdvZQJWJHIGOkjtYEztyzvmRuxH7/fqtdGzz9tbq6E/sMPO/Dwe30G/hCWyMeHnjWR44YUTKj1sUGimzC81Yh8/ULmrY/2Z0KkvnrE0ptMMFLCneHwyrxMm3eqz2azYdnteiiPTJCt1umBATeMh7w1SpeaNpshKC/RFoqHUBmbQv1F0Vw3gjp33ePyNBeTq+kEukg2dLOJjxh3iOR5LqS99WD3B3fKCd3EvWO7Q0E3ajLRZ3D1eujW5pPD5ke7H//ITL34qZvPWcbJvcc91ZwJaVM1GDT8kDqXSYzMVGmg11Gm8Pd146BaPf74/m69uu9pjemwTH+yXW8SjHK2RdTJBtAEiZhJIjIEoXTlF78QunJG3+4qHpRUZjnlNMaVCsY84ZC4BgQsXB9xd+iS49yOK/ZLt2biCWt5OXV3q026eGvT/K1fiFyfM97aGsRSgZPSlIYdYfxsLpaKSO/dHhxVMMdSry3t79rIBiQd+WpOurUGxV4RKRFgOQO9A2N2NvFnrx5xzfOt8aOjjhZSI2Hpzt748px0YUG+eDVOa5pqL9q7Tr9PX5/hIehaijKWHbXlEJ25j7fr0EM5wKh2pqLE0BgIsjTybcfCOpI24NQAnU09U2eKU/levvDXD+68eW/fk82ry0GUCeLRgWVOgqaXiWdpQeyc3EamaaftXbvwTKvV+Nn9h8XWkQY7h7hEA9sVccq0NzNG3OohxxxMur5ojE6KTVS/gvMZ+tmSP0mZhaLQ3SK6B2FAIZEkEOWuDhMmNT2oOoa9Ouv/asz6i7eVWt7rd8eqBdA/YCN9zIojWMfiQ4rgcR0PGVm4rq/fqT6z6oz7k93CaDWrf/U5fWMBbaC1Z/Y6f33So5W2V+zfvJQ7yNdee89GSF/TvULezR+jxyItG/Izq0KfJh2XvnrL/dm+9+/fk3tMdDCDG8vW3/vl83ihn283ht3JM1eX3vz5PnYRVaK5GQa3UfYRDR8OB74KlIRlQZkzHpYIFPCM0Ygh0/3qdYc0MHEMnDcwQoMBwCIRkjaGg/Pp5k/396dTe+vQRBYiFhq++wi1XlWeqqnw+Nlb7tdizn5N3y7Y2Vj468//A2Stf/zz33//8LuleuDhdzvnluK3d/bioYa2GUntDunQjeyKK2ul52+WlrP2ezvqcS3G+Rjb5MSBUFK7NNPaKxjnV/WEHf/49SJtMrwxYQ+FvmiOkazeeIhcE4eK5yJuGf7656zf/4lXP+ak+6G44sa1XpmsfeKygnS38AK0WU7HpO/U9IwyPSjXyTnnM9LVKyFfR7Qg8NXnZ7b/+HDbsxdyUXfM4AW+BvzOPT522nUq6PL1jMRxnk9Jl8/qyzPCV5vW4Hsf+XVbOy40/ujPavEYEYf2xIqRiieHDEWhwkAJL0CIAEOE+TFEnX0adnTr+ExwQMp1zKoof2p+x9HmwkEtX+HMgKlBtQtvF/C1aSYu1IzTCT1kx5gut1Xm3fxYQJ5LOGkUHMYqbTuwX7LWHyAFQbPH5pCp+cKrhnq2WGZCAv0TaMOPhYCy7oAE1SrNvqIjo0Y2T6NeN/TOgwOn24Gb2zqqx4NUPeAFUA7q9ZstZUgWNXM2mujQQmH9Ee+FDjhEaoW5ZiIxQ/+A/q07Ew0XveFXbpp/PDJsUxk0R6biqoRJnP4uIFIQCsRKiMm5AYYzIcsZCl1eBF80+dpNlHAJ/zFUQNHM334p+z/9ycmhMz46mSDjhr8t591B21+QpWsr0ua8v5TTzi7rSzneW0AO8TRfvjT+D7eDzX4A57n9ANfqyvfRWaiy6/Qseed0BOUtMTlStGOwhs6QszT0GFaDaiaOikOluEiKyE6IZIz9QKtQlSstO5cCAS+gyGYyfuOK2ugdF/q+adoXcj4txf08cby/sujPrY8qNbvQIv6SH+3Zz1wKJxPtP33nh93Ojy3TubRivPah1+mi1gtfXnnn56qaOWMtzLq3njCqg8BybhoNTbbz2sNdJRJRWuPAjYXFsBFGdqyJFIYfA0y6ugTebFo4qnMDhPgIt5cWGsaHKUeWRqeG6JGcCr8bC7htP1BpCQkgtG7xA4RMIHx4cGSAxDU49Q0IAQKCpG5KFfgff5tuMDLsooRLazEclhd15927vbGlF46mB1s+IgEXYsaTK4ErC9MLq8oTmxbcIxwMjeJuX2603Jtn3eOm/NaOdD6tXpwFwxsPK1PAmuz4cdsLI3q2Jj/FBO1QsNH1/uQN6btvOLtV7d++rhH1NwBeSEL3g0YYG9HoTgqVCQxL7jS9FRDqAtQlYXIDUtoKylE496opWMePC5RRKNtIM+gKxFBKJJ6g2xAYdMAf6cBlDkrD3eNRd4QJtYs1KRoHEOeWq+qgy9gRKj6SV++I1gAxcn8QpWATTY6F1mbAABsFg0fh4E/ll8/HCj3ap0p8KbP94X6FCQOCui3OyAjptITGULk7+8S2WkCdrMfNB53x+Tnp4S4gkEA0A4nU7dXZB+StCR5Q8BAcLdCajOWsDZggYXYng626tnpuJNsBpBQFuFp2N87o36quvbqP2qGsJsXoO5K4hiQ/enzytxZEUg2GC7wWYzJqdTE/oN4Gl4j0Ox2YcWXqOc0mJxiP0xfKfIJJRs/ysKn+m++51Pg6fa/UCL35GIVc57VHOtoji/HJ589PYoGBZOZMwOsi1UEshumUfjKsCf02SlGOjcm+eCERsnpv7DEFCkEcwtxALuXd3FAWIhlFAtyzHjAb8VGv1Gsfl9yDh9NgJDYcTh49GOtBJEQM2wagBz3NV+dWAuFI8OzCpdnIHAWCX/vyP7ix+WXZWjkubk2GXZpBEy+C3vBsrGFLDfryIMeaNOxlB14yXqxLX4MkXlUvXza/+pKOFlC7Y3AtyOdYZGeo5LvqLz6rXV91Pn1J6/p6qSEGoZyipUQiwS2gakSYweFCDJeO//oCEukI+o5HjPhtrnywu7pfj3TpnLTH9faoPRgbBg1HtdKRf/CR/CBv0BoCXGKP/VrTQ84JZrg98uaTVseLgNOKxELtPgNgxdQPpMb5gRDyB8fR8RR9KmImhaAe0jbmEOsEp7jcU987ClT7ynzSqHWc3cIwiga0CIQQKWKIBnED58JZORORo+5BpddoqdDBUWe/dkb54vXJaNIPm1A2rYAXhXfMLJOtWjGYGF+dPQ9SGPl6iuZo9d9a+YdT+2a+seU6A/W3fyN188KtX/nCf3Nl/XrYylxY3YRYiEc6ru6/8tQXkqGUM2pAdWg5ncFQTtaHtUZ7+6hC3SEWClAGaPchlaA+qw4UY3GJrqxzLifTkSfeWstp7z2GJO1vrkw6ro1EFsnYgxNojK4oMJIfiHISNTycB3vgc9mz0eDbD3TqEulU6vX7a2/edh7t13dPao/y3ePG+KQ+zNdHl1bTi9kIFfpKa1DvG68+tI6YHiY7sYA3tpV237t9KL21g96pEwoQBHvEzDZ4OE2GNwFYsQMYy6X0hQqn+CB8fCGm7pNwcbwBLSIOqx12rbABn1raPh6ExbQG0fSniMWBGfsawxKCa1ENXcw2RD0MOnKseioJXHccNKPTkUE7utmR8p1yqeS0nF4wNl4JL7bqkETwxyOmCq+lvlrtoNb+IVJyGvKDjHA4Pt6Gx7yYPdvukAEYMIv/3ld//ZnLNw9Kzf/3+39R2Xtf8abdjny3tk8tsTv2IkEDEnqhOQBVgcqj2xl1jvV7D4NffpkvOLNz070CA1zBi6lrObftuHOmXp046bQ6O2sU2zpegqgFoQAK23g8oJEDxw+GLFS0kaG+m1/WoisUffeLxa3jRrE1qXWEngDNk68/u76UCfIH5vphT2gf00z6wcPwnRPj2eXhcjpW7cof5rs47N7Ivl/o01ZkAygszKXVF56KpmPqTNS+tK6jK8gQs48e20UqH7rOgGTBmWMAM3G/yOu994vWxXibJIdQg6NC53noAAOX6TGSawyqes9CvVqG1wbYay4xhaelaFTiWh9u1Q7rZNF75BazaiqXiLeOQ7938qE98fp919HHPYlgBBF8DSQZKaE6f+bcxplnxURFe1SotgLdt/e2fuIx7TWxCapv5yj/ML+dTOxRw6J3PCox+ob+kIQ/qUMB4VPbLgMUhDPGDU40GrNEiFkOj2kX2gq3BFexEnNzUaPBiDZNyu/axdIUWCHzaIQi36mmJKaM6HXoqlBNLIO8XavUW+88KL52t1jtTnvD6WBk0yP8u1+7kY4y7sFnKh31LRwQ746cLFwfxmo9rARuQ0z13M9dWYQdPhfVsvQJF7KZRHgJVttS4j/7bOgf/VrspWezV9b0y7nppRX7+bO99SQ6DsFyCwQ3BTGwLeJOik+FsttUcvs9agRIn3H6QavQzgyiMxpJ9btm+wicoprMKi9cl1684eeizP2bItx+XIeZZCxnRUz1uYtXXrh4jm4D07s1RWfQsBnRktFzEfVstdPr2/Dvu+o3vvDpz936LC3ceCJXPfxwVn67kj/yB0f9bu1uXt6vnOjqXiJY2ykCDfamqtys9BmaTCWJh6c+ATKQxeNuClsiKfkaYrpBKcaIK2kh4Tc7Ko8xn2EM1BT9NXBPHz3yuy1bpb4COg8rgEk89QRiDh6RmaqkmZTe6Nw7qD08buFGWBdMFAHy3//mdWgJx+Xm/cPmvd3K2lw8DXSwSREeYoRGB5YLAY7ipN6PG26TGWcgtnx2WcgBsYjNlvP6R0P0Ri4vgTBnDuDQww+qzH+fXl/q/XzXP2qQk2JngPHyPJ4RCEwIWagFAlShuBQ0Y/wMh2LoTiLBoNgAABxc5Hh84wajgwfVjnvnQMAjByP5ypq1MT/ZPrJ3S917J4UH+1UEQ1qIA00npjL3wvo/QkIjG4PSO1dtHqvRrOFPRruH27AnFmdzvfrJ/t5esdIu9szo2c9/81M3z6899/HRdOtwB43y1Jz+xMLi3na1jiYCn5UlR//PElxP9ocyHn88rklOl2kuSpQMfeyjvBEy9JHsQaNt1dQPtkGhMXABSCmTaMSdP11/ylXiC7gGKjSIK0AJJvpLho1cghqh8pkn5vEQlWb3wXHrzftF4pPzyxn4oghI1EH+CEV/sA6cAyJ3ejKj5XSgMXAgD9V6ToXw3/E7Y1JG5c6e+9TaKK52PZTkZen4aISoOaTqkDp9fVvrAIknDuUs+UQBGN2JULyVhDAj6nK5mJEE2UGuI0Y5806D0bAHhnb1ogKEdCkb2FxU4YyUG4jfeaY5PSg5O3nm6iaurfxODfbOYNsdMfIgkjDXa502tbISTJnxturHmvnGUTQc+tbnv0b9TEneqE3iK5deSSw+rfpj1N/y1d6jQrHRgJCmsJhPxWPIy84krUwyiPVkEEjQohZKR2HqDCHBSNGgfFhAsEG5vascVKRr59TmSP6T1/ztXemDHXgRjCAUo7yEB8a4supYH5IJl5hMmzhe0FQjBrgEPRWxIjCvxPB375nzM9yZdx+W7x42clHrC0+vVeod7BbJM527U0cuFg4wLpXL0WiykjAKHXC5Itoqdz8hZPC2CEJJ7ebkqdWpSYPGkwt5oJyeQc7ZJol075eB+uEEVDILKLc+3hDALIhr4JlUF8E/I6jFyQAcg7DPVPwVTmJh3cSSM7BK8saHJeekRKGXOWDeQoYL7+hqbi76jWI7P/UfuwwRTszcWHuF4iaAnBPwJuM9NbMW6DuTmfT8mXT2h+/ee3hce7jz6Mlrzy4vr8MKeu/++/nG/nLyAaC9Qo3JN3adq+V4EfyU51faYmYLcSifhoVklAIYL0IRwJ6INoBVBJF+/8SfS/hbW3SOT4Vi+g75aiIlUjNpRDtcZGRkOtgZLAW3itwzCR5L6GeB6zKRxK22eiRQx43Rx/u1uUToV1+6gKx6bzA6qvaZcCD67ILwwkUiLRB9K2aTJEy/2if+EQ3FFuOgSRJPoUSED6iBP7PImFS/3pH74CZRM+vJUG5SIe/DI4UxS7wQwT7plT/qq2KeMk1dISUMLClmibIxqTLzS1FIwCeMRt6Va0Y46d3edvfz7kxKff6SdFT27+7YF87Y1Q5t9sRy+LO7xTuOuo2gLQVry1uajeT2qsWTzuOgWVTP3QQSph8eFu8dPawOOo/2dh4d3AkEosVS/aTSOmz9/KDycbkxIUZKxYESy9pgeHyMmgMgvAFVB54AbyCSWmJLrK7HGCiOM4+gzs/pl87J8xk9YbrJuGRlqRWL4076hYDLsMdqYXRYmNPOgkgqRLSBJ0CCNW55LUyH7TY6NFrd3mj8+LidjppffmoVYQWAs/ul3naZwJptxJSJoceiOssrQKa27dkwwuI+PciJDQZPohVMHInUGArr7PGVWWrr7kHe5byDC2HwCeL+KL7CxN+pkRJQUQPvo0wBBPAooLhOhb8RwQRCQbrKO5HUh8NBIgn6SDNr6lSTS2hxun4kaKeiDNIGLEOM62TMZ9div8CwuYAcYpSEO21dXfxmNBisVYcHld14IjhVgmpqRhsP3cGIE2ifXRqclE9owHXH3UbrpDk+CRknOM9CRQ5agDuoDEsTzSgeDehL8ElZb9YLtAQj1ciuMNYYR5YCCQVm8hAL7x16nQ4+EhFSJWz4iYjClLXiCbVPz596ClMayDjEBeA2i6Y3oVRv7D+97v53XxpT9Xzh/OSjA7cKGB+AmetlohY86W5/vFfu7SKHTbmCagj1GyGHi03DXWOBcAhuOkhJirXDyyBjQ3OdWF50LvhWbN5zqzaryX3hADAcuz8+nS/hSx8cK0ctMmUxDhRD74xYWFEwoobKk/EPqVJQvILclU5oQlWbcHjizi7ML63lgGhUW1Kh6IFZPsgDFYFu4QWVm2np2Y93H61nzu2Bx3S2rqS/FNIS7+49MnTzbPbybm2sBqIWjYhQmA8Yo/4TNSfdfvjyerQ+atEDD+jJW+vPQCv6hU9/w9TPFxrFxniYnolP66Pjirj+LL2oLhsMUtbRQBWPzAATzIrNA1ObExq4xMtMhwJ8U6t79+5OiT6F9j2yHiSGZPef3ACuPoskJA3lUlv9tefcV55wLy14pFf3D8U8AFR0kIjiRHPttoo9+lhMJuXQn/bfhA9nC7lEmGdid/BkfJtQZmVQjNgAFlT4aM5uUHeeXiJp4kiIt+QAAHekWtVj1thA3qoKR0J/nzNFDYAXZxe5EMTGvAWfkFl6wv6wYshB8HSu98or6YX58THKDD3+6fikEjiX+UY4uNieHMcDK0uxy+mIelCrlLr5gbI7GA3mY2emzF8cjXEj9eGJ+k/+8fqz12cvrURSiRAjJOElc+d+5RXr/Uf1UoXsvD+TWdtc/XQiFG6PzUeHj8fTXtySokPKSVDHAzESbzqBokfG22OLOFb0KBmURq1UzEXAMncbdqXolItuq+6ooMjBm40cgXE4TYY/CWNFXMESuh7dnlrXT8fkFzbFiKa1jHx5RoKrfNQQowIQbi80hliJwViIE7OqsaA4mPyGBcZg00Bn5grLjVdgyfgeBBvomwjSE1/z3ZDunsvwfwHr6I98pkOwjsSOcJv2av5BXWw2sdxkRPMOtyT2jFIEgvnU++iDJMJcJnG9gkFKzX5w0VzZgF2kkRAy8RdFgE4//OTC3y02jWL/I9lJpfTl+VDirb27rdHBxeXcyxe/XW/Z2Wiu0uvt1k4UY6i+/DK1O/Vnj5xyox3UR3jSC2sxEtQ7O2LGH1WbaDB+dXljLbhrDj8IJ56aSeUs66BQngybFALFcQAOQwzD6BwO4JS+CwLnJigBZqiQ60I8ZVNR0+VXStUOXUefX2lq4Laxr2zX6dpxvPh2UZVgKyS52JG/fg2EDB4eFI0SCUibSKoTzo8EKl24XPaYur5AHonmGpvHR/iEpcS8DFFGZoGBw4g5lazYqZUTF8ybj0vXF8T2sJqnmw6+CNwDH0d+a18CFM12sp+TIUrUYmvZNyA6nC8ht0NINsE+UzHlk/qQEWUA/bodiTBGWFqfg7EjxFVzgRfyzUZr/FHCShp6GCTno+o9MJZnFv3J2Ns7HuQbVBj2mROkq001PmtQh0IqpTuwZ2LMi/bWl0M/ebdBWXViQ1IkbLr88o2rB1XSiko4Oo8eyG5xe2LYk+F42Br3yadYM2F8Ie0IY4pv4LqCjBDDMbHvPCsRH2k+AQ8LI/J3rq74ivhusUr8KioAnyw9r0N0Ue74mzPS5XkCmEDI8mYzdJ3llMkXKekIKhI+m5j8Yk4M1ECLst4XX6S7l4UuSXYi/JMM1AXQNvEkm4Tn/+Sdvnjen42e/p5l5v/cm9M4mKFb7x5R2BWa1UwN4p/zV6w7NTsSMeqsuF8ITAK85UvIBrUHzMXSNla0W09FPtiyddXeKzCtai2mPONOZhvdhmYOrue+kbPWmRlQ70zqTROLfS7yIqWD+dQCrIjWsGsGMkjz6fe2UScbtQbKTNqt1kjYnK2DwWzaqbQDFM5HTrvZLn64B2RnFq9TaZb2S4/OZK8trT7VmvT0sI0BcgZ4GHHWwJ3jTMQgNSIGLKtYYSywuO/shPC3fHyWm9+frgeLcHo84ZBiWPmisNZ8P6+GTXhxnUEcdKREOjwYK5w7VBOfWJDRXwF4ci4rw90QU2IwiabAJeEtWS+Wm5XFZyCozlfY2dPYkaX0zmekGwucFq6a+B6cMCvOTxwshWXGrYoQDgQ8VX8h/C2uF7cUQ8QlowoKDgKnjWOPBBWTsIT2R8JPrQqvWyrz8nZSf3re+OXdSmE2lKnULciDaBBUa92jRsnRaqlUnrC2UC3gmmq98sjLw7VRL1yNGwZuB3yGXAaP7yntFlVcq9EHbxtLhtGi6++Wj/Otw42F5c2VcFiHWGGvhl/x+xtNs6Rm7TCly4yAFhHW4ZPFyrIJ2CNhdE+fU8Q5p+t6ujRixYVN4Kc4m/xgjZB7UVN+HJUJpK3p1Spyvi1fW/Tn4zh5/CSSlKKSK14JLWnSvYDwtJzQoBjNI0OQz4RBB4iFg1aLA0ZyUxxt4XjFPeRd1tPSi0AzQe0Q/VIIE2Lfp9ZMkh6U2ADxqWiqAM0h7uTf4qXxvfzz0wsEAZ+dwDvQvBTDDGht8ro0x8+fS5L9BCMy2GFTXdPs1W6/geZIq9/emIeQEGl3u9VBTdESE2QUpddn4mE1vj1VKsEYCOum+l/+3ZkXbzF52h9L0JUGsSBAVSgP8WJL/fQT5ktXgFzOVttQWWM3Vt3zS3aheaKbQuT2qDRuuaUL891seKzGgjryAHxcxFjExRayGBjctOVdzLj8GkannVuANQKrIVSSxNPyR37wW/4FT0vUEkhIyBjxV3wLbpacnu/Er7KyrFp3wAYI0CRzGxhaw+Xhb0+dpH/UFElvpc/sZpnMi3SbhcPZC08P/8iT56LSxayw5nw/TXbWV3xKX0xpuluQ3jpA+IEPTaQmDg02jZfGtOIGeBF+wyUQ94D/2FGeK4ZT45VJOY1/+JWljcjksxuJbouK0FmpfyYbT+UbVQZcnE+eh+VpBJMsGjrHkJ2pWH71c/KzV6V4cvrwKNpppLVSrdweBXeLdmeihU1vP0/QYG0m+tmwh1L4q7ed3YoKYRotnVI7GDiQ3rlfG3mhv/Vk3fEH0vD8+dnJfuPRoE2KJylzqWQslBjVA51+jFa1JSFgw3OySnxurArOBojz47xfKTKOQ2B4+QXDRA0O02Og83FASIV7E/FfKui+s62++UhZSEpLKa/RY06NdGuFTFt69bG8lmZ0pXzQENYDA7XXELHvaVwoNpW3FNDt0yXmA1Dj3K5Khw2xstgx3AkXiDI1XXrUpsjUuEn4DzTjMxHkhz30UyBhc5lPHcHpWTntv3JSCNIIFGodDBVhqHcuJPntHYHrn0q9mv+gcicid836ZqtdTpqju+1/h9DRuB2LJbdMuko9uoE8qd3vasCUbZvaZld747a8MDt4cjOhq5Gjkl6u99SAQ9/hX3x1bSbs3imMfq/f+6dfyfVqmhOXP6pAUaeONfn+4x+vE6TXLYSQGh2t1ZOfn19C3qWk1afZRSB+G1ZDB9UrAK9iC/oTlZFmZjI8DUS1o92Qircn1cETizlNZHC0EYhDBPydC3CaR4R1uz8SbY9CU9kuEREKa0DvhfyWn7vgOMXoOGHmOODsI4koNl0kH6c2TVJPp3rzaqfmjoNAPNoAsM/kwolQceL846t5q7hFNEW9EzkMwcrW1On+QBgo/DZfF/aH7eS7eWFg0oB1cLiE6kxyIo+RHDO2dJKvusP+ow6UiFHSqittBeUgezqDZv3Got4L7z2mc6daqUi7oWqHjdEH5d7Ocbg7Bcze1eZmpn0GfUvdMKBCDR1A3Kz7+QvqZmpw+04/Y0z/3vV4Wuo0yqXjo3ojPA+YpNd2b/cmRxWgeAMYnWFd/czGuZfn50LqyYf1SbHXgXDUn6Tp9IQtJy54nXqja4Uiejjk+53etDdFky+mKATOB02RyWKg8JbMj6KiOjsXu7qZwcGt55xma0THqt0ZFxo2YLpyy2uPsMLiwFqW3GJO6WkBgvXB4lF+YDbdKdKBd2TBxA6x+iJg5DeKnFBIs2V6k+DOQe/yJSCqdBjhpbJLpDKiReYy4tQHXoceLuEsJosN4JSI1zm9UHhu/kA3FhMUtZBEkt/ND9/+cF8+IW0AABiMSURBVGDE/MLUeuGiQk1p7+Qv6+Nc8xjnFR7ZzY1Vrz21uqh2zDhZb1qoOe3uoFxBrKYmkwf0PKNckXeOHDr39YYTMd21bGxCk5xl7rTn1+aBrJaLjVprDNkTRV6qIw9bMKvBhkfXk78BuuXpy0eO39+phuojkzCp2W3xTRBPYoJjH2WUH/r8awnrxnw8GVbtUqO41+bJWaQq5E6SezEsjhT/1AKE5CfX0f9r+g4kzAl/lUmY55ZiN86nzi2njyr97SL9Y46nCMZnwwz3ZKytiOhZcfM0XUqL6jd4b4UKGiYFeHQooEaDGgAD7hCRK+VrRjpwKZdSpmkYwMoFa4nTLnDjXDKKg0LZg7fAKHEfif3FZxWBLHULhPt4KwXU9OIZ68yNaGjWPOmCgMmsZ2+9tPKZUavSUzoAkB4eF1QvBjRofv7kiy9Mrl50b14eUTOGxRNSu4cnXEHSRUeMs5VRJun57S7FFKfX0+Zzyn//pb9xJnuhWS30au9nYtsIX6BtkMmiK+oenrSXdECko7EWDgfSrWaqYkdjgLGboxaF9WDmUjZ9a/mqhgaG19XDDVoEjw/0tj8xo+07lX44JJWPuzQm211SVs6+QPGLjuupVyRgx24T/D089Id9IFAcNlGOA3QK1GlzNZxJBLLW4NJCgMJ1oT5czIRimnPSsqclmpo+vvekI92w1HMzIfqRIrfiDbDd/AckkvIlcgMMNQM3TG0CDBH4VGg7wkKKqhEaFLRYsIWsL6wj9JvZaQTI8G0TwcWDKElfXqRgrqFe/UzqCy+m+xPrjUeHpcrk156++dWNX4G12Nzbf+78Riia+nflD2/XTNfDnk+avTwejvD67Xv+YKhuLFpXzjFPxenxcMBJNhaCs2kEStxM0oHz1mkYJpLqToWel6Re3nmwu1c7gNQRCqF4ZnWG36JIn/B+djBWbeWlzrRKgvv+fSbvqBGrFw73q5Nyt5d14OE4Rh8ot9q+kTl3ZuFcIhpyHUZMDH94v/mgJkJy+pqf5LEiuDw1EjxwwGMApKinZkJ+ymA5aLOoHPZ7R8inOZSgsFftHqcTepnUavUqrkexHjtEyI/WIjwhGqULcRUYId95Gr9gpUQ0KspwoqggJjARDpBbAZ4NGCKFB/VNdSeK7J4uof1Nbi4EcrBHjoi4SMJlYGzU15lemWSIaiiYDVoLKjhSpqPMmeuLS/q0Nh5lOpNW51z64vWFp/YeHYYmWtdpjNQtxRoGA6nu0Hm832uWp2PNHLrT9RyCzqE4QpJ4mi99Qb1+MUznFIV9TEa124locv9g+OEH9xvd7ub5Lynu3PYhAwa0Tn+l2UmkrdjNmY0Pq7vtNr3srhkcSHqf0dSjjlzv+ZRxhqCsLGstkzg/E5sF8JIxIqYSVYK77TI6Is3moHzUZsxptSdcHDEFqROhIb4R44vZTafC8XjEoknSGlNhYqQqIu5kPXNpzJa9nXepqoIkRhYsAafU11hfOlYYeTqh52YMIhxuVXciskHsClEvVUnWX/ji08od1TchKUaBzWMsHEhAXCxK19P5bIAKCtBuYuAxNsgSnAZeirlms0uB6GI0dS6+fi35S19ceP48usX+zgmU9OVfuvS3b8avpeS2Ir9qBXaZlDAZRh7fe9TQais3SufPjONRbGaF6uh7DyjYqUypHjOetD8tFLj3UY/zQMZYb3axrff3RYg2HSnfefy+2olE4/avb34xGJxJJYOXNpZu33/8/K1n6Mh8sPXgwVZdFILdAzJa3b8SUK5OrFcNN4xLM/S+oQYXrXg2qF5bEcipfLN7VG037XeY91Ud4RaU4VhG1xTTQ9LEQeYHZ5+PkcEZiJLL6P2tbr/vr8XJn+k6KzFLeXaDyEx+tE8DWBjoxpgnsetDeWXeOq5Qevgk06LpD/ASI66Jxj5tE9tJp8C2UgB0ik0q30Kbl2OOYWLVuRMErlBaqJlydabTAXqgTCzoj9ziUNu4Eb32ZBCJDQgjdC+HIwu9dYr2YaMXRCF+4Nw7HIbs8Ymzl6NSMvd2p71NRLt/HFjzLz7z2WdDjx8eWh96sd7WgfTv32A4IzdehWQArarZiYG34lydOU+7IaCl4xSWIpjMUQ/ZeKBxfqtFHaeXjdi8zU9+8EZz1On4Pgo0lxonTN+gAP/29INmdcUKsELNbn9k+s3zmfWVzAKcZ4h4EEvRf5MMr32/ftJgi9RUALcVnYlH5pP9VneSjBm1HtPOCGNEZIFTI7dkfaUuABfqBwTaTpKJeYD6RDcKqY3Rn78xpJGwlGYegjBc2OLJhGqHX6+CsvQZWQxgjyiWkiQkVewM7hfhBlEcEFpPlARZWuBZLmIBFiOsRP0HQ0cc6UQAVfp87GlEaMM5NfwV0ELupmcgQdvX3OMunGnv4pKXiDvojzd7gYN2R8ilO35x0DhU8vpIGY8ewk/aOfC7gysKQ9P29u/d32vN66AYaf3ostnq252BSpWEKTsmXT5Mqxpotjo9EuTjwjSb6dW6zKikBChCaxmqQBClGe3/fPfHSS+UNZMNuBTOKHonRPZy6NbdmLEU6Qe8Wq0peRbR6KjbC48CaAslyrhXTzvpTw5HQ0NuL8dm/8aVS6FoNBWLA+BqdHtb8k/i5iQdElFKi7G3BEA8OL7REM3IfJ8cx332YrzY1IGRMYOR0Qc+VSbXycJ5sARCgm9D8VPshPCv3GGRu9EKIw/AoNEqJftFm7wrcEMUnKdJoTskgD3cL2oM1Z5wCgSmVHXo1DeHBLOUl4laqfVjuCBsAVVCCIpmeKjZkS8sIbDrMbqyP1Qf1N3v/Kwd1ia0t8ju/86V31qeXR63O82aUizvnd1YK1cif/zDPyOHbhsUREr5+4In8fQ1nI3/9m2vUJl+6klreYlQ3AELivBcs2Rr7zwKvmAy8k++sIg99WfTqOpJj/bHBwV3ca4PCutRvTmfQ1NOf9153GOy4nRyJmsAICRmnkiBWotXt9tu81G3lRwZM9EY+lDPRtYlH6RsJayZh5V9oy0N44uPWgUkQKbVca0ln81qFZrpLKwohIm6Jnh8zjsG6vaudybbDof0swtB14f3a2BEV6bqHDEC1B4mR4LQskTHMQEEkBEJeEtmnE39dBAVJpDPym4dNVtB71Ix93DRHcG8gO7JSAMwQMzZ4Qbwr/jJOQAPyo3gb9NRSAEAeNweIhNB/2zEe35tFXHn6qBPlfOgPsmf0MxFhsG9uMgMVFSclUG5XHP1QDLy9sfjy1e+uLC5fFR8f5Ct1OInD3ak6T2Dse2plETFmvkWlOQRJb+4ZscTxkrO3y9Jg60eHCfstnNUF3WT6xvGtQvLzS7Hmcpcr9auUQTe2qEirqZTLkldrcmMctDqRrNlF/Xu2EUWpbewYK7kzIODsaxZVrzZHAUvh625TBR2zFzqMtFRe9DvDaq7rT03UGq1ZOYRbc7CL5f36rg7EVyzB3jEhCmSHXGWfZkJkcXOBCtCs4VJ2FaAIqtCwevekcQkFpCBAB7oL+BgItguugzoEgmfwsqKYhkRS6FFkgpQjM6oFGxy/SkdQ80QI6GpoxGhkgOLG6NpSZ0GnYA8YbjoblOvwvygPr2QNFHAHsi1JNox1fFJXsB7+RhxRf7FzQQ5473j5nduf2d1b3NJWtqrPYiUJvmHj+u11sHYQT+9VBUw3rML6ovPqf2Rce9Bp12n4MLH8IYVEIx+qTwq1YGUU/ye2gfHcFvccrb38OjkrQcWhX4eREy6Gcub66F4jEAleEz7hw68qK5rw75doKQPCBaErzRsBJROT+bkRMzhUiSaDc7ag046kwA2elSuN0ej3sA5rtsLa47lBoZM3XBV6qm4V3H2RFdGZJtIaWJ5KdGQChGbkTxDo2TSFIPNsdeiz3ua1kYsXANeQPQCB47XHp/iumgCIawHRtIGv+6mEYCUbFo3XGViHDTNAGDRiaRkTQzKutO3Oc1rKauhxCauAjcJ9RbeAksk6AKIrWW8t4sfU7wKh6c7JcmU3c0VDQ4Bmlvtbn3S8x/lO/dq43t+L+LsXnjCud1v1Ivm42KvEWhKA+3ieQYKaKjgAO2jN/7wQGJSGEdkr4j6hMygWFa/XEX3DDxTKNJvO5MRWT5aXEq5ytJQzZU8YGk+72pe2EjCJgIvmj92JrTM+ey+4OsSwJjM6XEVvl0PBoN6AGRAezIt1Q3mUCYs+7jS2e48RuxuLj1zZXnxfO7M9ZVNht1WCiUwLHs1FzPCw+M8MceUyYhWkdMHESTQQTrAOiMBTCmoJIgIVWFtKMfDt2Fz2AF+w1HlOFNeZh+x6ZDl5qPshAIKGqMEawWXYGjKcgYlEuwq3bpPqjqn1Qj2hgmtohRxWvVkTJHYYTEKlswAlz53UbdRgfXj4FOYb5yLQ78xMslQJq3l4ZhoY4Rba71wJmtMDFoV8gGz77zSCbrNHX0yUVfmepXKuFp3CGBI9m9eMq5ugO/D1fljQj7IsE1AMcJDnJZBeCZJrtSVd8fUisXlJR5wegLh8+FWrzly8lXUQMijKfcjIGxDKiYzxBeihjhqNdtTJRDSZ0LJM+k5X0vkgpQInO9tN4N6V0pWO9XkmVWdokEqnGz2JvpsKL2ZePunJebP8vxDERlSDlOGhEHE4LIcttQYScCI/hqsB9FvoTPDB4ywNhLVNLZMrCMa8nhknoQl7vDNp/kunDrCSxIrDglulYtFxkDbACFKeiynV4i9FHmZqA7RMBWuV4AjqPYIzJsgYQjnfOHCwhcuX80m5lVKrjaDLZWjUv79o4crZ3qFovXxcT9y1Z3K/rXz6oWzwV4/8Pa9UaU06Q85hmPS9ux8kA8GSaU3UCo1+dM33YurVB/jqwvObr5/9+Hk4FBRA7C7IO5JangpZpK9UG8eynqE8+BPh5wvBelahr2yQVYgANdJA6XnmOG4GYsHgMgFTER1Gb3rTBnihNDpyMyF5mPRxbCVWEjilnIhPTIaGY+PWygdlpvNYrMdU4cMWFnKzG2c3YxlkmsXBLnl8e6QutB8TKGVZul8hwtaPwqh6XS9WJRPKjOcD5aNLQjqRDKi6YiqD+EKq88gMFIqkUBoUi4icjpWk6yCf3KK9GIWjVvp+o2BkFtk51h3mvscf/7ITeLaUUrin/B76hLBlHzpWfNrz1/lE7EmDM0rtvoP98uinUF2EWgFA8Avx7cfkYT6y8uTSqsHqe/eDvAQQq1QKjFZX5CuX9QWspGxOzk5otCpLS2iugEmiU7j+PU7Xr2pNKqyw86LjIU+GxZWcgU827XGnX52BjHjKDLAVihYPukeFZoNnAi46JFwj7kcSaoWYIDxzlg3BYwNzVB0nQOJUatbsMe1c8gOO/GD4zJlz+Eg0G65F9bNtdzMWmqOlBKJn2Jlr+83+mGfyPZwuzsvRq2K8nNKKN2wjOpRfYoWMlmuwIUgi0AFDXPjg6AixCS9Ypk4DfSexJJxVTvg2tk8jjxFdIfqHgkwJkjUjvEx2BXuONafw46r56Lwatg3TqgJmPe0yc4uwsWhtPvE0+lrl1aSAYuR8XtV9NQnyebs24WTsBp4cvXiWW3+QdXb6z6gbLeQ86vUcW00Hy2Uw0aj5misrM2TA3nMv0EzLZ2cbK6qs3Gr1sRo01xihhWnynMGXiyk1C0FSazuhDKcRzzVZ265n7hM5Is6z2gwXsuEIgyqAf8dMIYd2GGM16bhqKjGxBnUieiGPUanICZ1WhkOiCHZjAfsC8BSpwYNstzud2mIyzPZ+LeefmEhFby0EAmFEC0LEBEyzrvYyA+8x/Sk0eK/f9TIhmnXq4AlsPVY5DM587A+RaaCpceWcCo/KUnS8mVCnSDGnvbIyGMxggQwxDP4Ukp4osFAtYfu+SkFhO0BwYClJOHgr06RWWpQtIjFAD2uCJYcn8y4Bs5izwusnl341Zc/k4lEh4MGRQjFCHdG1Qe7+wOnEjdy1VoR0gEFkof7MjWY1RWPKP64OFxfmdzbg/TvtzpKowtI3a/35EzGZ+hWwI+sbMjoSf38vo0m3F7Rz5fQzgGthVCd2jct4gqVnjB6nC7MyiDjEjkmtNKHgoLqugPISSO32YR2ow57VGtFOkOdhaYdHAqeKJFSExlSItA7vBmGFJk7VrJTHzbzLQ3EwFpcujarZ5DBgQgylXdPClsn+Ur/YIdiJ1FtoY1EwUIq3BrYZMIYZYwMfhVDHCKel2Qg5vh5TL44zgJhJFxrZ8Qxx7yIHyQNGBksErnYaQAjBQVlHwOlVPsiPzgt91NqFjdMoJ09oEQi36T4ynsR/uL/sUaQXriEbYbNjp0n1pO0BsrN0cPj8lExX2x3Rkqr0JysLJpDr3BMnyul37gcOSk4+wfOypw1k8YwKShXg/1v1NRWR0ayKxgAvA6isk+h+qc/n1baqKiR3AFc9Ec+M9qEcXOUGXQtgL8wvor8D66YQbUWudBJ3w6owiLVGafOKMWJb0W1ZA6SfgBzD4gMiZ4RLgG7BfPHdbPhVC6SvXlu/v8v6cx+07iDOL4Hy+4CCw4GGzs+KuJEzWFZUZ8q9aVVpf69fex7pFRKKjdt3MROHBswmMO7wAK7C+zRz9iS/WCL83fMfOc7M995/d1Oo2Sul0bPXTJsLrOm1B8O/Gle9cFMf7w7/9YdmhXfKnsbVWtjFQ6ZlLmImODoRVrPC8HdsJ40w3hSbi7xLT90sLJMnHEAJRaFg8xO4IQdUyugJAGWEkJbSpfZKuxXf0aoBZcgRS7sDUwymwd+xwEM5qlHGRYRg2wq1gnFaSkz4QWxDLy6adn+fPZqT7tjgl635QW3txMidgZc6ujoQlu8uxy9+S9CUSins9r23mN1oxKCWarl9WSSopmGDgPiBWx2uZRjzjcNZVgkikQnvtbtgNSl/eb4hcKUZY5snDyKo4jQI6cXDeQtNN2LY4FIeAfIANOC1WLTQENck2SzSlgQxwGV38QXfGhiKBVWL58YB/Xazwx6qxu8lh/YfTfTCv8e1gHOu9v2QakAzTK8mTJhaLxZk/7hz339q3u5C9KGaYMUWMw/tig5zJppCuk2CXQ2YI1ui8zEE4tPMTU5W6z2fcZGrTu63A/BwgrECQ5E9AVSwVH4a1Dp3SLrBwj001NMtCyXhoPPForzoBpAVl5S0dwZwB4SKlAbpGQQUbQsZbZY/dlp7ZXqKy365fjYVGs0YhCqB6vwihBgdcp8AOzGYBzXKgvK6N/+DSBXaY7kxKxC9defUsex3akoqPoiP8Q7Y0TjNdSsAC9JHPWGlPwi+o46SFc8MJ0VvCK/thEvsEQQgpK2Zr4b5iixSkpiATsT7hLkeZLRO6+WrHgLKap8uAzXcaR3g5uznjsZN5DBIzYwMojno5OtZ02mCaHFQ6GoeuhNvcbjyVLpXrvt04uYWS5Rfll244OGczVaj2bpdgVGnv1Q6mi7FgxyLHN6OIhy8Vpi+gRucvRh3yBB4YI59agGA5Y4uvL56dDCaAlzx8fmQmAQHo61RF6SDJFFB/lwBgV1kslkb6RDloZmmaQnlk8cXWq0bz375aiYVKF+IqOHcO7Yj993ruYT4Jnx/XOJET9fgmJj21jfjXTmmfUQT/Nwl9RqkDCmxUq9/JZNfW27mtW2VvNQY2oh7fPFMjnStNchA6pENOiTEDeSJMjuVxqhZp6KRaI8hG5fGlvo1Y0SuuU3t81kjS6kEkeOVdqvMq0lYT5Ke9Mwjw/2GR/qbCROLmiPJ0f7e48cc6dW5YBS1+/JtAuyS6SPMhjtcFnsupszl8v0BZm9upJvXSqn14vuOGZpyiKrToSM+h14HtDCyjLjXMq3Hhwsjoc8YhSJAWGQI+fJnqkNJL3xDTwGtco58bKAMnARKV8dXA2y5PiRwhb/dF9cBLYVOCtoi0o3TiLvCrvAV+eb83RqXJtVm2qM7u36U79/t5jtV+3zVvK+xzw1zanFSCCiFcPtvbiS4g1M3MsXiesyx1RDebtzg/RmRhM2fHvrWmcEeP4LHC2wjOgRvEPptzql6AYszCcjrwyCJjFRrOS5znz/hEpHypsFuGkEH3Te4qhNg61S0GbA6uDe0GaUe2VOSxQQJM6PR/WakfvrPMCRvtq3nx42VyEgu4BjpM8/DsfV+g7v8vubj2/PPl1AUvfhvsLffnhSnKX+IO5Nw8GMxhi1VjLoBsXCswoSBwIZNRgbzrdkxx7Iav7DI9kJeJi9DaqjaFzJPOnCYAXFhwOLuDQ8iT9J8mCX+OLQnGBT7gDpLrnZBLrw3Lg86rW5Ovd9ACQY2BdkysjhnLyuXHspnYGZBXG+LOYLw1G22ww6g8XJcwI29fQsa1Sz2VylEKRIXXA+Gww1p5geNrN/PuRogGA0Yrudm/v6s6cJglhBJNoSjFrhktIhk1LYikWSpKpgjkzN/w/S3f7e2b69QQAAAABJRU5ErkJggg=="; - const FIZZ_SYSTEM_PROMPT: &str = r#"You are Fizz. You are a careful, direct engineering agent with a subtle bee theme: collaborative, industrious, and precise. Keep the bee motif light — no catchphrases, no cartoon impersonation, and no performative roleplay. Reliability beats performance theater. # Subagents and Peers @@ -42,7 +40,7 @@ Your name is Fizz. You are friendly, helpful, and quietly industrious — more h const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona { id: "builtin:fizz", display_name: "Fizz", - avatar_url: Some(FIZZ_AVATAR), + avatar_url: None, system_prompt: FIZZ_SYSTEM_PROMPT, name_pool: &[ "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Bumble", @@ -50,7 +48,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[BuiltInPersona { "Orchard", "Buzz", ], model: None, - runtime: None, + runtime: Some("goose"), }]; const RETIRED_PERSONAS: &[(&str, &str)] = &[ diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 808546aa4..6924e5187 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -69,6 +69,7 @@ mod tests { auth_tag: Some("tag".into()), relay_url: "ws://localhost:3000".into(), avatar_url: None, + avatar_url_cleared: false, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 3469dc80d..410f0ed62 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -210,6 +210,7 @@ pub async fn restore_managed_agents_on_launch( name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), + avatar_url_cleared: record.avatar_url_cleared, auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), agent_command: record.agent_command.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd..3c6c2fbe5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1364,6 +1364,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f8a922da5..6be96057c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -130,6 +130,7 @@ fn fixture( auth_tag, relay_url: "ws://localhost:3000".into(), avatar_url: None, + avatar_url_cleared: false, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index d9e272066..002b0dfd7 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -279,6 +279,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + avatar_url_cleared: false, acp_command: String::new(), agent_command: String::new(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..db6246020 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -104,6 +104,9 @@ pub struct ManagedAgentRecord { /// `#[serde(default)]` so pre-existing records deserialize as `None`. #[serde(default)] pub avatar_url: Option, + /// True when `avatar_url: None` came from an explicit user clear. + #[serde(default)] + pub avatar_url_cleared: bool, pub acp_command: String, pub agent_command: String, pub agent_args: Vec, @@ -230,6 +233,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -424,6 +428,9 @@ pub struct UpdateManagedAgentRequest { /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, + /// Absent = don't touch. null = clear. "url" = set. + #[serde(default)] + pub avatar_url: Option>, /// Absent = don't touch. null = clear to agent default. "id" = set. #[serde(default)] pub model: Option>, @@ -685,7 +692,13 @@ mod tests { assert_eq!(record.auth_tag, None); assert_eq!(record.avatar_url, None); + assert!(!record.avatar_url_cleared); assert_eq!(record.pubkey, "abcd1234"); + + let mut value = serde_json::to_value(&record).expect("should serialize"); + value["avatar_url_cleared"] = true.into(); + let cleared: ManagedAgentRecord = serde_json::from_value(value).unwrap(); + assert!(cleared.avatar_url_cleared); } /// Agent records WITH an auth_tag round-trip correctly through serde. diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..433212ad3 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: Exclude; thread?: string; threadRootId?: string; }; @@ -16,8 +20,11 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; +function profileViewValue( + value: unknown, +): Exclude | undefined { + const view = parseProfilePanelView(value); + return view && view !== "summary" ? view : undefined; } function validateChannelSearch( diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..0fad54adc 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,9 +15,16 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: Exclude; }; +function profileViewValue( + value: unknown, +): Exclude | undefined { + const view = parseProfilePanelView(value); + return view && view !== "summary" ? view : undefined; +} + function validatePulseSearch( search: Record, ): PulseRouteSearch { @@ -22,10 +33,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: profileViewValue(search.profileView), }; } diff --git a/desktop/src/features/agents/agentReuse.test.mjs b/desktop/src/features/agents/agentReuse.test.mjs index 88b632490..4274b671d 100644 --- a/desktop/src/features/agents/agentReuse.test.mjs +++ b/desktop/src/features/agents/agentReuse.test.mjs @@ -12,7 +12,6 @@ import { const PUB_A = "a".repeat(64); const PUB_B = "b".repeat(64); -const PUB_C = "c".repeat(64); function makeAgent(overrides = {}) { return { @@ -173,22 +172,19 @@ test("pickPreferredManagedAgent: undefined updatedAt treated as epoch 0", () => test("findReusablePersonaAgent: finds agent with matching personaId", () => { const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A }); - const channelMembers = new Set([PUB_B]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); + const result = findReusablePersonaAgent([agent], "persona-1"); assert.equal(result, agent); }); -test("findReusablePersonaAgent: excludes agent already in channel", () => { +test("findReusablePersonaAgent: reuses agent already in channel", () => { const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A }); - const channelMembers = new Set([PUB_A]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); - assert.equal(result, undefined); + const result = findReusablePersonaAgent([agent], "persona-1"); + assert.equal(result, agent); }); test("findReusablePersonaAgent: excludes agent with different personaId", () => { const agent = makeAgent({ personaId: "persona-2", pubkey: PUB_A }); - const channelMembers = new Set([PUB_B]); - const result = findReusablePersonaAgent([agent], "persona-1", channelMembers); + const result = findReusablePersonaAgent([agent], "persona-1"); assert.equal(result, undefined); }); @@ -207,20 +203,18 @@ test("findReusablePersonaAgent: prefers running agent", () => { status: "running", updatedAt: "2025-01-01T00:00:00Z", }); - const channelMembers = new Set([PUB_C]); - const result = findReusablePersonaAgent( - [stopped, running], - "p1", - channelMembers, - ); + const result = findReusablePersonaAgent([stopped, running], "p1"); assert.equal(result.id, "r"); }); -test("findReusablePersonaAgent: pubkey comparison is case-insensitive", () => { +test("findReusablePersonaAgent: channel membership does not affect reuse", () => { const agent = makeAgent({ personaId: "p1", pubkey: PUB_A.toUpperCase() }); const channelMembers = new Set([PUB_A]); - const result = findReusablePersonaAgent([agent], "p1", channelMembers); - assert.equal(result, undefined); + const result = findReusableAgent([agent], channelMembers, { + personaId: "p1", + command: "goose", + }); + assert.equal(result, agent); }); // --- findReusableGenericAgent --- diff --git a/desktop/src/features/agents/agentReuse.ts b/desktop/src/features/agents/agentReuse.ts index b0d800703..0066b60bd 100644 --- a/desktop/src/features/agents/agentReuse.ts +++ b/desktop/src/features/agents/agentReuse.ts @@ -49,13 +49,8 @@ export function pickPreferredManagedAgent(agents: ManagedAgent[]) { export function findReusablePersonaAgent( agents: ManagedAgent[], personaId: string, - channelMemberPubkeys: ReadonlySet, ): ManagedAgent | undefined { - const candidates = agents.filter( - (agent) => - agent.personaId === personaId && - !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), - ); + const candidates = agents.filter((agent) => agent.personaId === personaId); return pickPreferredManagedAgent(candidates); } @@ -88,11 +83,7 @@ export function findReusableAgent( }, ): ManagedAgent | undefined { if (input.personaId) { - return findReusablePersonaAgent( - agents, - input.personaId, - channelMemberPubkeys, - ); + return findReusablePersonaAgent(agents, input.personaId); } if (!input.systemPrompt?.trim()) { return findReusableGenericAgent( diff --git a/desktop/src/features/agents/channelAgents.test.mjs b/desktop/src/features/agents/channelAgents.test.mjs new file mode 100644 index 000000000..e8d7781a2 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { respondToUpdateForReusedAgent } from "./channelAgents.ts"; + +const PUBKEY = "a".repeat(64); + +function agent(overrides = {}) { + return { + id: "agent-1", + pubkey: PUBKEY, + name: "Reusable", + personaId: "persona-1", + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentArgs: [], + mcpCommand: "", + turnTimeoutSeconds: 320, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: null, + avatarUrl: null, + model: null, + mcpToolsets: null, + envVars: {}, + status: "running", + pid: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + lastStartedAt: null, + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + logPath: null, + startOnAppLaunch: false, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +test("respondToUpdateForReusedAgent resets omitted mode to owner-only", () => { + assert.deepEqual( + respondToUpdateForReusedAgent( + agent({ + respondTo: "anyone", + respondToAllowlist: [PUBKEY], + }), + {}, + ), + { + respondTo: "owner-only", + respondToAllowlist: [], + }, + ); +}); + +test("respondToUpdateForReusedAgent leaves matching owner-only agents unchanged", () => { + assert.equal(respondToUpdateForReusedAgent(agent(), {}), null); +}); + +test("respondToUpdateForReusedAgent carries explicit allowlist choices", () => { + assert.deepEqual( + respondToUpdateForReusedAgent(agent(), { + respondTo: "allowlist", + respondToAllowlist: [PUBKEY], + }), + { + respondTo: "allowlist", + respondToAllowlist: [PUBKEY], + }, + ); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 4ce40ec41..89a82f3e1 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -69,7 +69,7 @@ export type CreateChannelManagedAgentInput = { respondTo?: RespondToMode; /** Hex pubkeys for allowlist mode. */ respondToAllowlist?: string[]; - /** Skip reuse logic and always create a fresh agent instance. */ + /** Skip generic-agent reuse. Persona-backed agents always reuse by personaId. */ forceNewInstance?: boolean; }; @@ -91,6 +91,32 @@ export type CreateChannelManagedAgentsResult = { failures: CreateChannelManagedAgentBatchFailure[]; }; +export function respondToUpdateForReusedAgent( + agent: ManagedAgent, + input: Pick< + CreateChannelManagedAgentInput, + "respondTo" | "respondToAllowlist" + >, +): Pick< + CreateChannelManagedAgentInput, + "respondTo" | "respondToAllowlist" +> | null { + const nextRespondTo = input.respondTo ?? "owner-only"; + const nextAllowlist = + nextRespondTo === "allowlist" ? (input.respondToAllowlist ?? []) : []; + const allowlistChanged = + agent.respondToAllowlist.join(",") !== nextAllowlist.join(","); + + if (agent.respondTo === nextRespondTo && !allowlistChanged) { + return null; + } + + return { + respondTo: nextRespondTo, + respondToAllowlist: nextAllowlist, + }; +} + export async function attachManagedAgentToChannel( channelId: string, input: AttachManagedAgentToChannelInput, @@ -259,33 +285,26 @@ export async function createChannelManagedAgent( throw new Error("Agent name is required."); } - // Smart reuse: if a managed agent with the same personaId already exists - // and is not already in this channel, attach it instead of creating a new one. + // Persona-backed agents are singleton by personaId: adding a persona to any + // channel should attach the existing agent key instead of creating another. if ( input.personaId && - !input.forceNewInstance && context?.managedAgents && context.channelMemberPubkeys ) { const reusable = findReusablePersonaAgent( context.managedAgents, input.personaId, - context.channelMemberPubkeys, ); if (reusable) { // Apply the caller's respondTo settings so the user's permission // choice in the dialog is always honored, even when reusing. - const needsRespondToUpdate = - input.respondTo && input.respondTo !== "owner-only"; - const updatedAgent = needsRespondToUpdate + const respondToUpdate = respondToUpdateForReusedAgent(reusable, input); + const updatedAgent = respondToUpdate ? ( await updateManagedAgent({ pubkey: reusable.pubkey, - respondTo: input.respondTo, - respondToAllowlist: - input.respondTo === "allowlist" - ? input.respondToAllowlist - : undefined, + ...respondToUpdate, }) ).agent : reusable; @@ -318,17 +337,12 @@ export async function createChannelManagedAgent( context.channelMemberPubkeys, ); if (reusable) { - const needsRespondToUpdate = - input.respondTo && input.respondTo !== "owner-only"; - const updatedAgent = needsRespondToUpdate + const respondToUpdate = respondToUpdateForReusedAgent(reusable, input); + const updatedAgent = respondToUpdate ? ( await updateManagedAgent({ pubkey: reusable.pubkey, - respondTo: input.respondTo, - respondToAllowlist: - input.respondTo === "allowlist" - ? input.respondToAllowlist - : undefined, + ...respondToUpdate, }) ).agent : reusable; diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 02b9129ba..65ea29432 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -264,11 +264,11 @@ export function useUpdateManagedAgentMutation() { ); }, onSettled: async (_data, _error, variables) => { - // Backend republishes kind:0 on a name change (sync_managed_agent_profile), - // so the relay has fresh profile data — but the desktop's React Query cache - // for ["user-profile", pubkey] has a 60s staleTime and will not refetch on + // Backend republishes kind:0 on name/avatar changes, so the relay has + // fresh profile data — but the desktop's React Query cache for + // ["user-profile", pubkey] has a 60s staleTime and will not refetch on // its own. Invalidate explicitly so the profile pane re-renders against - // the new display name / about / NIP-05 immediately. Also poke any + // the new display name/avatar immediately. Also poke any // ["users-batch", ...] entries that include this pubkey so sidebar member // rows, channel header chips, and message author labels refresh too. const lowerPubkey = variables.pubkey.toLowerCase(); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 0bf30b70d..ea162ff73 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -44,7 +44,7 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { return "Stop"; } - return agent.status === "stopped" ? "Respawn" : "Spawn"; + return "Start agent"; } export function resolveManagedAgentChannelId( diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx index 2aa95372e..378684907 100644 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ b/desktop/src/features/agents/ui/AgentGroupRows.tsx @@ -16,6 +16,7 @@ export type AgentGroupRowsProps = { selectedLogAgentPubkey: string | null; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; + onOpenProfile?: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -36,6 +37,7 @@ export function AgentGroupRows({ selectedLogAgentPubkey, onAddToChannel, onDelete, + onOpenProfile, onSelectLogAgent, onStart, onStop, @@ -61,6 +63,7 @@ export function AgentGroupRows({ presenceLookup={presenceLookup} onAddToChannel={onAddToChannel} onDelete={onDelete} + onOpenProfile={onOpenProfile} onSelectLogAgent={onSelectLogAgent} onStart={onStart} onStop={onStop} diff --git a/desktop/src/features/agents/ui/AgentIdentityCard.tsx b/desktop/src/features/agents/ui/AgentIdentityCard.tsx new file mode 100644 index 000000000..90630a374 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentIdentityCard.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/shared/lib/cn"; +import { AgentProviderCollage } from "./AgentProviderCollage"; + +type AgentIdentityCardProps = { + ariaLabel: string; + avatarUrl?: string | null; + dataTestId: string; + label: string; + modelLabel: string; + onClick: () => void; +}; + +export function AgentIdentityCard({ + ariaLabel, + avatarUrl, + dataTestId, + label, + modelLabel, + onClick, +}: AgentIdentityCardProps) { + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentProviderCollage.tsx b/desktop/src/features/agents/ui/AgentProviderCollage.tsx new file mode 100644 index 000000000..9fbd0d025 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentProviderCollage.tsx @@ -0,0 +1,60 @@ +import type { CSSProperties } from "react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; + +type ClusterLayout = { + avatarStyle: CSSProperties; +}; + +type AgentProviderCollageProps = { + avatarUrl?: string | null; + label: string; +}; + +const CLUSTER_CENTER_TOP_PERCENT = 50; +const AGENT_INITIAL_AVATAR_SIZE = 152; + +export function AgentProviderCollage({ + avatarUrl, + label, +}: AgentProviderCollageProps) { + const { avatarStyle } = buildClusterPoints(); + + return ( +
+
+
+ {avatarUrl ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function buildClusterPoints(): ClusterLayout { + return { + avatarStyle: { + left: "50%", + top: `${CLUSTER_CENTER_TOP_PERCENT}%`, + }, + }; +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 9bcedca67..dadb07f31 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -1,5 +1,12 @@ import * as React from "react"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { AgentPersona } from "@/shared/api/types"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const AgentsView = React.lazy(async () => { @@ -7,12 +14,63 @@ const AgentsView = React.lazy(async () => { return { default: module.AgentsView }; }); +type ProfilePanelTarget = + | { kind: "pubkey"; pubkey: string } + | { kind: "persona"; persona: AgentPersona }; + export function AgentsScreen() { + const identityQuery = useIdentityQuery(); + const [profilePanelTarget, setProfilePanelTarget] = + React.useState(null); + const threadPanelWidth = useThreadPanelWidth(); + const openDmMutation = useOpenDmMutation(); + const { goChannel } = useAppNavigation(); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + return ( -
- }> - - -
+ + setProfilePanelTarget({ kind: "persona", persona }) + } + onOpenProfilePanel={(pubkey) => + setProfilePanelTarget({ kind: "pubkey", pubkey }) + } + > +
+
+ }> + + + {profilePanelTarget ? ( + setProfilePanelTarget(null)} + onOpenDm={handleOpenDm} + onResetWidth={threadPanelWidth.onResetWidth} + onResizeStart={threadPanelWidth.onResizeStart} + persona={ + profilePanelTarget.kind === "persona" + ? profilePanelTarget.persona + : undefined + } + pubkey={ + profilePanelTarget.kind === "pubkey" + ? profilePanelTarget.pubkey + : undefined + } + widthPx={threadPanelWidth.widthPx} + /> + ) : null} +
+
+
); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569..92e0c7330 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,4 @@ +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; @@ -21,6 +22,7 @@ import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; export function AgentsView() { + const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); const agents = useManagedAgentActions(); const personas = usePersonaActions(); const teamActions = useTeamActions( @@ -34,14 +36,6 @@ export function AgentsView() { }, ); - const isActionPending = - agents.isPending || - personas.isPending || - teamActions.exportTeamJsonMutation.isPending || - teamActions.createTeamMutation.isPending || - teamActions.updateTeamMutation.isPending || - teamActions.deleteTeamMutation.isPending; - return ( <>
{ - agents.setActionNoticeMessage(null); - agents.setActionErrorMessage(null); - agents.setAgentToAddToChannel(agent); - }} - onBulkRemoveStopped={() => { - void agents.handleBulkRemoveStopped(); - }} - onBulkStopRunning={() => { - void agents.handleBulkStopRunning(); - }} onCreateAgent={() => { agents.setIsCreateOpen(true); }} - onDeleteAgent={(pubkey) => { - void agents.handleDelete(pubkey); - }} - onSelectLogAgent={agents.setLogAgentPubkey} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); + onOpenAgentProfile={(pubkey) => { + openProfilePanel?.(pubkey); }} - onStopAgent={(pubkey) => { - void agents.handleStop(pubkey); + onOpenPersonaProfile={(persona) => { + openPersonaProfilePanel?.(persona); }} - onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { - void agents.handleToggleStartOnAppLaunch( - pubkey, - startOnAppLaunch, - ); - }} - selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} personas={personas.libraryPersonas} @@ -128,13 +87,6 @@ export function AgentsView() { isPersonasPending={personas.isPending} onCreatePersona={personas.openCreate} onChooseCatalog={personas.openCatalog} - onDuplicatePersona={personas.openDuplicate} - onEditPersona={personas.openEdit} - onExportPersona={personas.handleExport} - onDeactivatePersona={(persona) => { - void personas.handleSetActive(persona, false, "library"); - }} - onDeletePersona={personas.openDelete} onImportPersonaFile={(fileBytes, fileName) => { void personas.handleImportFile(fileBytes, fileName); }} @@ -157,10 +109,11 @@ export function AgentsView() { onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} onExport={teamActions.handleExportTeam} - onImportFile={teamActions.handleImportFile} - onInstallFromDirectory={teamActions.handleInstallFromDirectory} onSync={teamActions.handleSyncTeam} onRevealInFinder={teamActions.handleRevealInFinder} + onImportTeamFile={(fileBytes, fileName) => { + void teamActions.handleImportFile(fileBytes, fileName); + }} onAddToChannel={teamActions.setTeamToAddToChannel} personas={personas.libraryPersonas} teams={teamActions.teams} @@ -219,10 +172,7 @@ export function AgentsView() { isImportPending={ personas.personaImportActions.isApplyingPersonaImportUpdate } - isPending={ - personas.createPersonaMutation.isPending || - personas.updatePersonaMutation.isPending - } + isPending={personas.isPending} runtimes={personas.acpRuntimesQuery.data ?? []} runtimesLoading={personas.acpRuntimesQuery.isLoading} onImportUpdateFile={ @@ -300,6 +250,7 @@ export function AgentsView() { } }} onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} + onInstallFromDirectory={teamActions.handleInstallFromDirectory} onSubmit={teamActions.handleTeamSubmit} open={teamActions.teamDialogState !== null} personas={personas.libraryPersonas} diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx index 5e0ba0f6c..5b2a0e612 100644 --- a/desktop/src/features/agents/ui/BatchImportDialog.tsx +++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx @@ -9,6 +9,7 @@ import { import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas"; import { createPersona } from "@/shared/api/tauriPersonas"; +import { resolveImportedPersonaAvatarUrl } from "@/shared/avatars/gooseAppAvatarRefs"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { @@ -18,6 +19,7 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { buildBatchImportPersonaInput } from "./batchImportPersonaInput"; type BatchImportDialogProps = { fileName: string; @@ -91,13 +93,7 @@ export function BatchImportDialog({ }); try { - await createPersona({ - displayName: persona.displayName, - avatarUrl: persona.avatarDataUrl ?? undefined, - systemPrompt: persona.systemPrompt, - runtime: persona.runtime ?? undefined, - model: persona.model ?? undefined, - }); + await createPersona(buildBatchImportPersonaInput(persona)); completed += 1; setImportedCount(completed); setItemStatuses((prev) => { @@ -178,7 +174,7 @@ export function BatchImportDialog({ onClick={(e: React.MouseEvent) => e.stopPropagation()} /> diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx new file mode 100644 index 000000000..229c54f61 --- /dev/null +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Plus } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +type CreateIdentityCardProps = React.ButtonHTMLAttributes & { + ariaLabel: string; + dataTestId: string; + label: string; +}; + +export const CreateIdentityCard = React.forwardRef< + HTMLButtonElement, + CreateIdentityCardProps +>(function CreateIdentityCard( + { ariaLabel, className, dataTestId, label, ...buttonProps }, + ref, +) { + return ( + + ); +}); diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx new file mode 100644 index 000000000..71fa31065 --- /dev/null +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -0,0 +1,59 @@ +import { UserRound } from "lucide-react"; + +import { getInitials } from "@/shared/lib/initials"; +import { cn } from "@/shared/lib/cn"; + +const IDENTITY_INITIAL_AVATAR_CLASS_NAMES = [ + "bg-muted text-foreground", + "bg-secondary text-secondary-foreground", + "bg-accent text-accent-foreground", + "bg-card text-card-foreground", + "bg-popover text-popover-foreground", + "bg-background text-foreground", +] as const; + +type IdentityInitialsAvatarProps = { + className?: string; + colorIndex?: number; + colorSeed?: string; + label: string; + size: number; +}; + +export function IdentityInitialsAvatar({ + className, + colorIndex, + colorSeed, + label, + size, +}: IdentityInitialsAvatarProps) { + const initials = getInitials(label); + const seed = colorSeed ?? (label || "agent"); + const paletteIndex = colorIndex ?? getStableColorIndex(seed); + const colorClassName = + IDENTITY_INITIAL_AVATAR_CLASS_NAMES[ + paletteIndex % IDENTITY_INITIAL_AVATAR_CLASS_NAMES.length + ]; + const fontSize = Math.round(Math.min(40, Math.max(22, size * 0.28))); + + return ( + + {initials.length > 0 ? initials : } + + ); +} + +function getStableColorIndex(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 556f83a72..3066b4d75 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -55,6 +55,7 @@ export function ManagedAgentRow({ presenceLookup, onAddToChannel, onDelete, + onOpenProfile, onSelectLogAgent, onStart, onStop, @@ -73,6 +74,7 @@ export function ManagedAgentRow({ presenceLookup: PresenceLookup; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; + onOpenProfile?: (pubkey: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -128,9 +130,13 @@ export function ManagedAgentRow({