Skip to main content

nexus_core/
tools.rs

1//! Tools the model can call mid-response, advertised as nine consolidated
2//! names (`batch`, `skills`, `scripts`, `search`, `fetch_url`,
3//! `research_lookup`, `files`, `app`, `media`) that dispatch onto a larger
4//! set of specialized implementations below. Concrete (no trait) —
5//! there's exactly one implementation and no need for one yet.
6
7use std::fmt::Write as _;
8use std::path::PathBuf;
9
10use base64::Engine;
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13
14use crate::provider::ToolDef;
15use crate::provider::openrouter::{OpenRouter, normalize_video_params};
16use crate::skills::{load_skills_from_dirs, resolve_skill_dir, resolve_skill_file, skill_body};
17
18const MAX_TOOL_RESULT_CHARS: usize = 8_000;
19/// Maximum sub-operations in one `batch` call — bounds execution time and
20/// the size of the combined result.
21const MAX_BATCH_CALLS: usize = 8;
22/// Combined-result cap for a `batch` call. Each sub-result is already capped
23/// at `MAX_TOOL_RESULT_CHARS`; this lets a few of them through together
24/// while still bounding what a runaway batch pushes into the conversation.
25const MAX_BATCH_RESULT_CHARS: usize = MAX_TOOL_RESULT_CHARS * 4;
26
27pub struct ToolBox {
28    /// App-managed destination for newly created or downloaded skills.
29    pub skills_dir: PathBuf,
30    /// Skill roots in precedence order. These include the app-managed root and
31    /// read-only project/user Agent Skills roots discovered at startup.
32    skill_dirs: Vec<PathBuf>,
33    /// Base URL of a `SearXNG` instance (e.g. `http://localhost:8080`), no
34    /// trailing slash. Free and self-hosted — no API key needed.
35    pub searxng_url: Option<String>,
36    /// `LangSearch` API key (free tier, no card): <https://langsearch.com/dashboard>
37    pub langsearch_key: Option<String>,
38    /// Which backend `search(mode=web)` prefers: "auto" (`LangSearch`, then `SearXNG`,
39    /// `DuckDuckGo`, and Brave), or an explicit "langsearch"/"searxng"/"duckduckgo".
40    pub search_provider: String,
41    /// When true, `defs()`/`run()` restrict to `search`/`fetch_url` only —
42    /// used for deep-research searcher agents, which must never reach
43    /// `scripts`/`app`/`media` tools even if hallucinated.
44    research_only: bool,
45    /// Domains a per-space setting always excludes from `search(mode=web)` results
46    /// (appended to any `exclude_domains` the model passes).
47    pub blocked_domains: Vec<String>,
48    /// Main db path for tool connections. Connections open the db with its
49    /// sibling `cache.db` attached (`open_attached`), so both durable tables
50    /// (`session_sources`, `citations`, `files`) and device-local ones
51    /// (`web_cache`, `file_chunks`, `model_prices`) resolve on one
52    /// connection. `None` disables the cache-backed tools (some tests).
53    db_path: Option<PathBuf>,
54    /// Set for follow-up turns inside a `/research` session: enables the
55    /// `research_lookup(scope=session_sources)` over that session's gathered source bundle.
56    research_session_id: Option<String>,
57    client: reqwest::Client,
58    files: Option<FilesCtx>,
59    apps: Option<AppsCtx>,
60    /// Whether the current model supports image inputs. When false, tool
61    /// image results are returned as text references instead of being
62    /// injected as vision content (which would cause a 400).
63    pub supports_images: bool,
64    /// When true, `fetch_cached` never hits the network on a cache miss —
65    /// used for the Verifier stage's quote-checking pass, which must only
66    /// ever see pages the searchers actually gathered, never fresh fetches.
67    cache_only: bool,
68    /// Provider + model for AI image generation. `None` = tool disabled.
69    pub image_gen_backend: Option<(OpenRouter, String)>,
70    /// Directory to save generated images into / search for reference images.
71    pub space_files_dir: PathBuf,
72    pub space_apps_dir: PathBuf,
73    /// Directory holding space-local scripts (created by the model via the
74    /// `scripts` tool).
75    pub space_scripts_dir: PathBuf,
76    /// Current session id — for attaching generated images to a message.
77    pub session_id: String,
78    /// Provider + model for video generation. `None` = tools hidden.
79    pub video_gen_backend: Option<(OpenRouter, String)>,
80}
81
82/// Where the file tools read from: the shared db plus the space to scope to.
83/// The toolbox opens its own short-lived connection per call — the app's
84/// `Db` handle stays on the UI task and is never shared with the stream task.
85pub struct FilesCtx {
86    pub db_path: std::path::PathBuf,
87    pub space_id: String,
88    /// (provider, embedding model) for semantic search; None = keyword only.
89    pub embedder: Option<(crate::provider::openrouter::OpenRouter, String)>,
90}
91
92/// Where the app tools write: the active space's apps dir, plus the
93/// registry, server port and space metadata. Only present while the server
94/// runs.
95pub struct AppsCtx {
96    pub dir: PathBuf,
97    pub server_port: u16,
98    /// Public host base, when the daemon is exposed through a tunnel.
99    pub public_base: Option<String>,
100    pub registry: crate::appserver::AppRegistry,
101    pub space_name: String,
102    pub space_id: String,
103    pub space_db_path: PathBuf,
104    pub files_dir: PathBuf,
105    pub session_id: String,
106}
107
108fn tool_def(name: &str, description: &str, parameters: serde_json::Value) -> ToolDef {
109    ToolDef {
110        name: name.to_string(),
111        description: description.to_string(),
112        parameters,
113    }
114}
115
116fn required_arg(v: &serde_json::Value, key: &str) -> Result<(), String> {
117    match v.get(key).and_then(|value| value.as_str()) {
118        Some(value) if !value.trim().is_empty() => Ok(()),
119        _ => Err(format!("missing required field: {key}")),
120    }
121}
122
123fn required_array(v: &serde_json::Value, key: &str) -> Result<(), String> {
124    match v.get(key).and_then(|value| value.as_array()) {
125        Some(values) if !values.is_empty() => Ok(()),
126        _ => Err(format!("missing required field: {key}")),
127    }
128}
129
130// Long by design (tool dispatch).
131#[allow(clippy::too_many_lines)]
132/// Normalize advertised consolidated calls onto the specialized implementations below.
133/// The retired names (`skill_admin`, `app_inspect`, `run_python`, …) are intentionally
134/// still accepted by `run()` for persisted/replayed calls, but never returned by `defs()`.
135fn public_call(name: &str, args: &str) -> Result<(String, String), String> {
136    let public = matches!(
137        name,
138        "skills"
139            | "scripts"
140            | "search"
141            | "research_lookup"
142            | "files"
143            | "app"
144            | "media"
145            | "skill_admin"
146            | "app_inspect"
147            | "app_modify"
148            | "app_assets"
149            | "script_files"
150            | "video_transform"
151            | "video_references"
152    );
153    if !public {
154        return Ok((name.to_string(), args.to_string()));
155    }
156    let value: serde_json::Value =
157        serde_json::from_str(args).map_err(|e| format!("invalid tool arguments: {e}"))?;
158    let action = |key: &str| {
159        value
160            .get(key)
161            .and_then(|v| v.as_str())
162            .filter(|v| !v.trim().is_empty())
163            .ok_or_else(|| format!("missing required field: {key}"))
164    };
165    let mapped = match name {
166        "skills" => match action("action")? {
167            "load" => {
168                required_arg(&value, "name")?;
169                "skill"
170            }
171            "create" => {
172                required_arg(&value, "name")?;
173                required_arg(&value, "description")?;
174                "create_skill"
175            }
176            "install" => {
177                required_arg(&value, "source")?;
178                "install_skill"
179            }
180            other => return Err(format!("invalid action for skills: {other}")),
181        },
182        "scripts" => match action("action")? {
183            "list" => "list_scripts",
184            "read" => {
185                required_arg(&value, "path")?;
186                "read_script"
187            }
188            "write" => {
189                required_arg(&value, "path")?;
190                if value.get("content").and_then(|v| v.as_str()).is_none() {
191                    return Err("missing required field: content".to_string());
192                }
193                "write_script"
194            }
195            "edit" => {
196                required_arg(&value, "path")?;
197                required_array(&value, "edits")?;
198                "edit_script"
199            }
200            "run" => {
201                required_arg(&value, "path")?;
202                "run_script"
203            }
204            "python" => {
205                required_arg(&value, "code")?;
206                required_arg(&value, "name")?;
207                "run_python"
208            }
209            "install" => {
210                required_array(&value, "packages")?;
211                "install_packages"
212            }
213            other => return Err(format!("invalid action for scripts: {other}")),
214        },
215        "skill_admin" => match action("action")? {
216            "create" => {
217                required_arg(&value, "name")?;
218                required_arg(&value, "description")?;
219                "create_skill"
220            }
221            "install" => {
222                required_arg(&value, "source")?;
223                "install_skill"
224            }
225            other => return Err(format!("invalid action for skill_admin: {other}")),
226        },
227        "search" => match action("mode")? {
228            "web" => {
229                required_arg(&value, "query")?;
230                "web_search"
231            }
232            "academic" => {
233                required_arg(&value, "query")?;
234                "academic_search"
235            }
236            "discussion" => {
237                required_arg(&value, "query")?;
238                "discussion_search"
239            }
240            other => return Err(format!("invalid mode for search: {other}")),
241        },
242        "research_lookup" => match action("scope")? {
243            "session_sources" => {
244                required_arg(&value, "query")?;
245                "search_sources"
246            }
247            "citations" => "list_citations",
248            other => return Err(format!("invalid scope for research_lookup: {other}")),
249        },
250        "files" => match action("action")? {
251            "search" => {
252                required_arg(&value, "query")?;
253                "search_files"
254            }
255            "read" => {
256                required_arg(&value, "name")?;
257                "read_file"
258            }
259            "pdf_page" => {
260                required_arg(&value, "name")?;
261                if value
262                    .get("page")
263                    .and_then(serde_json::Value::as_u64)
264                    .is_none()
265                {
266                    return Err("missing required field: page".to_string());
267                }
268                "read_pdf_page"
269            }
270            other => return Err(format!("invalid action for files: {other}")),
271        },
272        "app" => match action("action")? {
273            "read" => {
274                required_arg(&value, "app")?;
275                required_arg(&value, "path")?;
276                "read_app_file"
277            }
278            "search" => {
279                required_arg(&value, "app")?;
280                required_arg(&value, "pattern")?;
281                "grep_app"
282            }
283            "write" => {
284                required_arg(&value, "app")?;
285                required_arg(&value, "path")?;
286                required_arg(&value, "content")?;
287                "write_file"
288            }
289            "patch" => {
290                required_arg(&value, "app")?;
291                required_arg(&value, "path")?;
292                required_array(&value, "edits")?;
293                "edit_file"
294            }
295            "diff" => {
296                required_arg(&value, "app")?;
297                required_arg(&value, "path")?;
298                if value.get("content").and_then(|v| v.as_str()).is_none() {
299                    return Err("missing required field: content".to_string());
300                }
301                "diff_app"
302            }
303            "list" => "list_images",
304            "copy_file" => {
305                required_arg(&value, "app")?;
306                required_arg(&value, "file_name")?;
307                "copy_file_to_app"
308            }
309            "copy_images" => {
310                required_arg(&value, "app")?;
311                required_array(&value, "image_ids")?;
312                "copy_images_to_app"
313            }
314            "init" => {
315                required_arg(&value, "app")?;
316                "init_app"
317            }
318            "build" => {
319                required_arg(&value, "app")?;
320                "build_app"
321            }
322            other => return Err(format!("invalid action for app: {other}")),
323        },
324        "app_inspect" => match action("action")? {
325            "read" => {
326                required_arg(&value, "app")?;
327                required_arg(&value, "path")?;
328                "read_app_file"
329            }
330            "search" => {
331                required_arg(&value, "app")?;
332                required_arg(&value, "pattern")?;
333                "grep_app"
334            }
335            other => return Err(format!("invalid action for app_inspect: {other}")),
336        },
337        "app_modify" => match action("action")? {
338            "write" => {
339                required_arg(&value, "app")?;
340                required_arg(&value, "path")?;
341                required_arg(&value, "content")?;
342                "write_file"
343            }
344            "patch" => {
345                required_arg(&value, "app")?;
346                required_arg(&value, "path")?;
347                required_array(&value, "edits")?;
348                "edit_file"
349            }
350            "diff" => {
351                required_arg(&value, "app")?;
352                required_arg(&value, "path")?;
353                if value.get("content").and_then(|v| v.as_str()).is_none() {
354                    return Err("missing required field: content".to_string());
355                }
356                "diff_app"
357            }
358            other => return Err(format!("invalid action for app_modify: {other}")),
359        },
360        "app_assets" => match action("action")? {
361            "list" => "list_images",
362            "copy_file" => {
363                required_arg(&value, "app")?;
364                required_arg(&value, "file_name")?;
365                "copy_file_to_app"
366            }
367            "copy_images" => {
368                required_arg(&value, "app")?;
369                required_array(&value, "image_ids")?;
370                "copy_images_to_app"
371            }
372            other => return Err(format!("invalid action for app_assets: {other}")),
373        },
374        "script_files" => match action("action")? {
375            "list" => "list_scripts",
376            "write" => {
377                required_arg(&value, "path")?;
378                if value.get("content").and_then(|v| v.as_str()).is_none() {
379                    return Err("missing required field: content".to_string());
380                }
381                "write_script"
382            }
383            "read" => {
384                required_arg(&value, "path")?;
385                "read_script"
386            }
387            "edit" => {
388                required_arg(&value, "path")?;
389                required_array(&value, "edits")?;
390                "edit_script"
391            }
392            other => return Err(format!("invalid action for script_files: {other}")),
393        },
394        "media" => match action("action")? {
395            "generate_image" => {
396                required_arg(&value, "prompt")?;
397                "generate_image"
398            }
399            "generate_video" => {
400                required_arg(&value, "prompt")?;
401                "generate_video"
402            }
403            "edit" => {
404                required_arg(&value, "video_id")?;
405                "edit_video"
406            }
407            "extract_frame" => {
408                required_arg(&value, "video_id")?;
409                "extract_frame"
410            }
411            "stitch" => {
412                required_array(&value, "video_ids")?;
413                "stitch_videos"
414            }
415            "save_reference" => {
416                required_arg(&value, "name")?;
417                required_arg(&value, "image_id")?;
418                required_arg(&value, "description")?;
419                "save_reference"
420            }
421            "list_references" => "list_references",
422            "delete_reference" => {
423                required_arg(&value, "name")?;
424                "delete_reference"
425            }
426            other => return Err(format!("invalid action for media: {other}")),
427        },
428        "video_transform" => match action("action")? {
429            "edit" => {
430                required_arg(&value, "video_id")?;
431                "edit_video"
432            }
433            "extract_frame" => {
434                required_arg(&value, "video_id")?;
435                "extract_frame"
436            }
437            "stitch" => {
438                required_array(&value, "video_ids")?;
439                "stitch_videos"
440            }
441            other => return Err(format!("invalid action for video_transform: {other}")),
442        },
443        "video_references" => match action("action")? {
444            "save" => {
445                required_arg(&value, "name")?;
446                required_arg(&value, "image_id")?;
447                required_arg(&value, "description")?;
448                "save_reference"
449            }
450            "list" => "list_references",
451            "delete" => {
452                required_arg(&value, "name")?;
453                "delete_reference"
454            }
455            other => return Err(format!("invalid action for video_references: {other}")),
456        },
457        _ => unreachable!(),
458    };
459    Ok((mapped.to_string(), args.to_string()))
460}
461
462/// The tool-calling seam: everything an agent loop needs from the tool
463/// layer — definitions for the request, read-only classification for
464/// parallelizing independent calls, and execution. `ToolBox` is the local
465/// implementation; a Phase 4 remote implementation (nexus host) speaks the
466/// same methods over the wire, and the loops don't know the difference.
467/// `supports_images`/`space_files_dir` are required because the tool loop
468/// injects image references from tool results as vision content (the host
469/// that runs the tools is the only one who knows the model's vision
470/// support and where the space's files live).
471pub trait ToolExecutor: Send + Sync + 'static {
472    /// Tool definitions to attach to the request (see `ToolBox::defs`).
473    fn defs(&self) -> Vec<ToolDef>;
474    /// Whether a call is read-only (see `is_read_only_tool`) — parallel
475    /// calls run concurrently only when every one of them is.
476    fn is_read_only(&self, name: &str, args: &str) -> bool;
477    /// Run one tool call, returning `(result, status-label)`. Boxed future
478    /// so the trait stays dyn-compatible (the loops hold `Arc<dyn
479    /// ToolExecutor>`).
480    fn run<'a>(
481        &'a self,
482        name: &'a str,
483        args: &'a str,
484    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, String)> + Send + 'a>>;
485    /// Whether the current model accepts image inputs.
486    fn supports_images(&self) -> bool;
487    /// The active space's files dir, for resolving image references in tool
488    /// results.
489    fn space_files_dir(&self) -> Option<std::path::PathBuf>;
490}
491
492impl ToolExecutor for ToolBox {
493    fn defs(&self) -> Vec<ToolDef> {
494        ToolBox::defs(self)
495    }
496
497    fn is_read_only(&self, name: &str, args: &str) -> bool {
498        is_read_only_tool(name, args)
499    }
500
501    fn run<'a>(
502        &'a self,
503        name: &'a str,
504        args: &'a str,
505    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, String)> + Send + 'a>> {
506        Box::pin(ToolBox::run(self, name, args))
507    }
508
509    fn supports_images(&self) -> bool {
510        self.supports_images
511    }
512
513    fn space_files_dir(&self) -> Option<std::path::PathBuf> {
514        Some(self.space_files_dir.clone())
515    }
516}
517
518impl ToolBox {
519    /// All config knobs; kept flat because ~17 test call sites construct
520    /// this with inline `None`/default args — a config struct would churn
521    /// every one of them for no readability gain.
522    #[allow(clippy::too_many_arguments)]
523    pub fn new(
524        skills_dir: PathBuf,
525        searxng_url: Option<String>,
526        langsearch_key: Option<String>,
527        search_provider: String,
528        blocked_domains: Vec<String>,
529        db_path: Option<PathBuf>,
530        files: Option<FilesCtx>,
531        apps: Option<AppsCtx>,
532    ) -> Self {
533        Self {
534            skill_dirs: vec![skills_dir.clone()],
535            skills_dir,
536            searxng_url,
537            langsearch_key,
538            search_provider,
539            research_only: false,
540            blocked_domains,
541            db_path,
542            research_session_id: None,
543            client: reqwest::Client::new(),
544            files,
545            apps,
546            cache_only: false,
547            image_gen_backend: None,
548            supports_images: false,
549            space_files_dir: PathBuf::new(),
550            space_apps_dir: PathBuf::new(),
551            space_scripts_dir: PathBuf::new(),
552            session_id: String::new(),
553            video_gen_backend: None,
554        }
555    }
556
557    /// Replace the skill roots used for reads while retaining `skills_dir` as
558    /// the app-managed install/create destination.
559    #[must_use]
560    pub fn with_skill_dirs(mut self, skill_dirs: Vec<PathBuf>) -> Self {
561        self.skill_dirs = skill_dirs;
562        self
563    }
564
565    /// A toolbox restricted to `search`/`fetch_url` — for deep-research
566    /// searcher agents, which get no filesystem/app/script access.
567    pub fn research(
568        searxng_url: Option<String>,
569        langsearch_key: Option<String>,
570        search_provider: String,
571        blocked_domains: Vec<String>,
572        db_path: Option<PathBuf>,
573    ) -> Self {
574        let mut tb = Self::new(
575            PathBuf::new(),
576            searxng_url,
577            langsearch_key,
578            search_provider,
579            blocked_domains,
580            db_path,
581            None,
582            None,
583        );
584        tb.research_only = true;
585        tb
586    }
587
588    /// Attach a research session id, enabling `research_lookup` for follow-up
589    /// turns in that session's chat. Also merges any domains the user has
590    /// discarded in this session into `blocked_domains`, so a later
591    /// `search`/`fetch_url` call excludes them the same way the global
592    /// setting does.
593    #[must_use]
594    pub fn with_research_session(mut self, session_id: String) -> Self {
595        if let Some(db_path) = &self.db_path
596            && let Ok(conn) = rusqlite::Connection::open(db_path)
597            && let Ok(hosts) = crate::db::discarded_domains(&conn, &session_id)
598        {
599            self.blocked_domains.extend(hosts);
600        }
601        self.research_session_id = Some(session_id);
602        self
603    }
604
605    /// Restrict `fetch_url` to serving from `web_cache` only — a cache miss
606    /// returns `[not cached]` instead of fetching. Used for the Verifier's
607    /// quote-checking pass (Task 8).
608    #[must_use]
609    pub const fn cache_only(mut self) -> Self {
610        self.cache_only = true;
611        self
612    }
613
614    fn files_count(&self) -> u64 {
615        let Some(ctx) = &self.files else { return 0 };
616        rusqlite::Connection::open(&ctx.db_path)
617            .ok()
618            .and_then(|conn| crate::db::count_files(&conn, &ctx.space_id).ok())
619            .unwrap_or(0)
620    }
621
622    fn citation_count(&self) -> u64 {
623        let Some(ctx) = &self.files else { return 0 };
624        rusqlite::Connection::open(&ctx.db_path)
625            .ok()
626            .and_then(|conn| {
627                conn.query_row(
628                    "SELECT COUNT(*) FROM citations WHERE space_id = ?1",
629                    [&ctx.space_id],
630                    |row| row.get::<_, i64>(0),
631                )
632                .ok()
633            })
634            .unwrap_or(0)
635            .max(0)
636            .unsigned_abs()
637    }
638
639    /// Resolve which backend to actually use for this call. An explicit
640    /// choice ("langsearch"/"searxng"/"duckduckgo") is used as-is — if it's
641    /// not configured, that's a clear error rather than a silent swap to
642    /// something else the user didn't pick. "auto" (the default) tries
643    /// `LangSearch`, `SearXNG`, `DuckDuckGo`, and finally Brave, continuing past
644    /// transport errors, bot challenges, and empty result pages.
645    async fn search(
646        &self,
647        query: &str,
648        recency: Option<&str>,
649        include_domains: &[String],
650        exclude_domains: &[String],
651    ) -> anyhow::Result<Vec<SearchHit>> {
652        let query = rewrite_query_with_domains(query, include_domains, exclude_domains);
653        match self.search_provider.as_str() {
654            "langsearch" => match &self.langsearch_key {
655                Some(key) => langsearch_search(&self.client, key, &query, recency).await,
656                None => anyhow::bail!("LangSearch selected but no API key is configured"),
657            },
658            "searxng" => match &self.searxng_url {
659                Some(url) => searxng_search(&self.client, url, &query, recency).await,
660                None => anyhow::bail!("SearXNG selected but no instance URL is configured"),
661            },
662            "duckduckgo" => duckduckgo_search(&self.client, &query).await,
663            // Auto mode is deliberately failover-based. A configured hosted
664            // backend can still be unavailable (for example, LangSearch's
665            // API host has occasionally had TLS/DNS problems), and a single
666            // outage should not turn an otherwise usable search tool into an
667            // error.
668            _ => {
669                let mut failures = Vec::new();
670
671                if let Some(key) = &self.langsearch_key {
672                    match langsearch_search(&self.client, key, &query, recency).await {
673                        Ok(hits) if !hits.is_empty() => return Ok(hits),
674                        Ok(_) => failures.push("LangSearch returned no results".to_string()),
675                        Err(error) => failures.push(format!("LangSearch: {error}")),
676                    }
677                }
678                if let Some(url) = &self.searxng_url {
679                    match searxng_search(&self.client, url, &query, recency).await {
680                        Ok(hits) if !hits.is_empty() => return Ok(hits),
681                        Ok(_) => failures.push("SearXNG returned no results".to_string()),
682                        Err(error) => failures.push(format!("SearXNG: {error}")),
683                    }
684                }
685
686                match duckduckgo_search(&self.client, &query).await {
687                    Ok(hits) if !hits.is_empty() => return Ok(hits),
688                    Ok(_) => failures.push("DuckDuckGo returned no results".to_string()),
689                    Err(error) => failures.push(format!("DuckDuckGo: {error}")),
690                }
691
692                // HTML search endpoints increasingly return bot-challenge
693                // pages with a successful HTTP status. Treat an empty parse as
694                // a backend failure and give auto mode one more independent,
695                // keyless search source before reporting no results.
696                match brave_search(&self.client, &query).await {
697                    Ok(hits) => Ok(hits),
698                    Err(error) => {
699                        failures.push(format!("Brave: {error}"));
700                        anyhow::bail!("all web search backends failed: {}", failures.join("; "))
701                    }
702                }
703            }
704        }
705    }
706
707    /// Fetch through the cache: serve a fresh (<24h) cached copy unless
708    /// `force_fresh`, else live-fetch and write through. Cache read/write
709    /// failures degrade to a live fetch — a broken db must never block a
710    /// tool call.
711    async fn fetch_cached(&self, url: &str, force_fresh: bool) -> anyhow::Result<String> {
712        let url_norm = normalize_url(url);
713        if !force_fresh
714            && let Some(db_path) = &self.db_path
715            && let Ok(conn) = crate::db::open_attached(db_path)
716            && let Ok(Some((_, text, fetched_at))) = crate::db::cache_get(&conn, &url_norm)
717            && crate::db::is_fresh(&fetched_at, chrono::Utc::now())
718        {
719            return Ok(text);
720        }
721        if self.cache_only {
722            return Ok("[not cached]".to_string());
723        }
724        let text = if is_youtube_url(url) {
725            fetch_youtube_transcript(&self.client, url).await?
726        } else {
727            fetch_url_text(&self.client, url).await?
728        };
729        if let Some(db_path) = &self.db_path
730            && let Ok(conn) = crate::db::open_attached(db_path)
731        {
732            let _ = crate::db::cache_put(&conn, &url_norm, url, None, &text);
733        }
734        Ok(text)
735    }
736
737    /// Tool definitions to attach to the request, or empty to send a request
738    /// identical to one from before tool-calling existed (keeps models that
739    /// don't support tools working unchanged). `search` always works —
740    /// it prefers configured API backends, then uses keyless HTML fallbacks
741    /// when those are unavailable, so it needs no setup.
742    // Long by design (tool-definition table).
743    #[allow(clippy::too_many_lines)]
744    pub fn defs(&self) -> Vec<ToolDef> {
745        let mut defs = Vec::new();
746        defs.push(tool_def(
747            "batch",
748            "Run several independent tool operations in ONE call — multiple searches, multiple file\
749             searches/reads, or multiple app/script writes. Every result comes back in a single\
750             round-trip, each labeled [n/N]. Prefer this over calling tools one by one whenever you\
751             need several operations at once. Sub-calls use the same public tool names and\
752             parameters as normal calls. Never nest batch inside batch; keep dependent steps (e.g.\
753             write then edit the same file) as separate calls.",
754            serde_json::json!({
755                "type": "object",
756                "properties": {
757                    "calls": {
758                        "type": "array",
759                        "description": "up to 8 operations, run in order",
760                        "items": {
761                            "type": "object",
762                            "properties": {
763                                "tool": { "type": "string", "description": "public tool name, e.g. search, fetch_url, files, app, scripts, research_lookup, skills" },
764                                "arguments": { "type": "object", "description": "that tool's parameters, same shape as a normal call" }
765                            },
766                            "required": ["tool"]
767                        }
768                    }
769                },
770                "required": ["calls"]
771            }),
772        ));
773        let has_skills = !load_skills_from_dirs(&self.skill_dirs).is_empty();
774        let mut skills_actions = vec!["create", "install"];
775        if has_skills {
776            skills_actions.insert(0, "load");
777        }
778        defs.push(tool_def(
779            "skills",
780            "Manage Agent Skills discovered from app, project, and user/global roots. action=load returns a skill's full instructions (SKILL.md by default, or a specific relative file); action=create and action=install write only to the app-managed root.",
781            serde_json::json!({
782                "type": "object",
783                "properties": {
784                    "action": { "type": "string", "enum": skills_actions },
785                    "name": { "type": "string", "description": "skill name for load/create" },
786                    "file": { "type": "string", "description": "optional path within the skill for load; defaults to SKILL.md" },
787                    "description": { "type": "string", "description": "short description for create" },
788                    "body": { "type": "string", "description": "skill instructions for create" },
789                    "overwrite": { "type": "boolean", "description": "replace an existing skill (default false)" },
790                    "source": { "type": "string", "description": "GitHub owner/repo/path for install" }
791                },
792                "required": ["action"]
793            }),
794        ));
795        defs.push(tool_def(
796            "scripts",
797            "Everything script-related in one tool. action=list/read/write/edit manage files inside the space scripts directory (confined paths, hash-line editing); action=run executes an existing skill or space script; action=python writes and runs inline Python in the space scripts environment (persists unless temporary=true); action=install adds packages to a skill virtualenv, an app's npm dependencies, or the shared space-script Python virtualenv (at most one target).",
798            serde_json::json!({
799                "type": "object",
800                "properties": {
801                    "action": { "type": "string", "enum": ["list", "read", "write", "edit", "run", "python", "install"] },
802                    "path": { "type": "string", "description": "script path relative to the scripts dir" },
803                    "content": { "type": "string", "description": "complete script content for write" },
804                    "offset": { "type": "integer" },
805                    "limit": { "type": "integer", "description": "maximum 200 lines" },
806                    "edits": { "type": "array", "items": { "type": "object", "properties": { "hash": { "type": "string", "description": "hash from the latest read" }, "line": { "type": "integer", "description": "optional 1-based line number to disambiguate repeated lines" }, "new": { "type": ["string", "null"] } }, "required": ["hash"] } },
807                    "code": { "type": "string", "description": "Python source for python" },
808                    "name": { "type": "string", "description": "confined .py filename for python" },
809                    "temporary": { "type": "boolean", "description": "delete the script after python runs (default false)" },
810                    "skill": { "type": "string", "description": "skill name for run (unless space=true), or pip target for install" },
811                    "space": { "type": "boolean", "description": "run from the space scripts directory" },
812                    "args": { "type": "array", "items": { "type": "string" }, "description": "command-line arguments for run/python" },
813                    "packages": { "type": "array", "items": { "type": "string" }, "description": "packages for install" },
814                    "app": { "type": "string", "description": "app npm target for install" }
815                },
816                "required": ["action"]
817            }),
818        ));
819        defs.push(tool_def(
820            "search",
821            "Search the web, scholarly literature, or HN/Reddit discussions. mode=web uses the configured web backend; academic preserves Semantic Scholar metadata; discussion preserves HN and Reddit engagement metadata.",
822            serde_json::json!({
823                "type": "object",
824                "properties": {
825                    "mode": { "type": "string", "enum": ["web", "academic", "discussion"] },
826                    "query": { "type": "string" },
827                    "recency": { "type": "string", "enum": ["day", "week", "month", "year"] },
828                    "include_domains": { "type": "array", "items": { "type": "string" } },
829                    "exclude_domains": { "type": "array", "items": { "type": "string" } },
830                    "limit": { "type": "integer", "description": "maximum academic results (default 10, max 20)" }
831                },
832                "required": ["mode", "query"]
833            }),
834        ));
835        defs.push(tool_def(
836            "fetch_url",
837            "Fetch an arbitrary URL as readable, paged text. Uses the 24-hour cache unless fresh=true; supports PDFs, YouTube transcripts, and research verifier cache-only mode.",
838            serde_json::json!({
839                "type": "object",
840                "properties": {
841                    "url": { "type": "string" },
842                    "offset": { "type": "integer" },
843                    "limit": { "type": "integer", "description": "maximum 200 lines" },
844                    "fresh": { "type": "boolean" }
845                },
846                "required": ["url"]
847            }),
848        ));
849        let has_session_sources = self.research_session_id.is_some();
850        let has_citations = self.citation_count() > 0;
851        if has_session_sources || has_citations {
852            let mut scopes = Vec::new();
853            if has_session_sources {
854                scopes.push("session_sources");
855            }
856            if has_citations {
857                scopes.push("citations");
858            }
859            defs.push(tool_def(
860                "research_lookup",
861                "Look up previously gathered research material. scope=session_sources searches this research session's source bundle; scope=citations searches citations saved across this space.",
862                serde_json::json!({
863                    "type": "object",
864                    "properties": {
865                        "scope": { "type": "string", "enum": scopes },
866                        "query": { "type": "string", "description": "keywords; optional for citations, required for session_sources" }
867                    },
868                    "required": ["scope"]
869                }),
870            ));
871        }
872        if self.files_count() > 0 {
873            defs.push(tool_def(
874                "files",
875                "Work with imported space files. action=search performs semantic/keyword search, read pages extracted text, and pdf_page returns an imported PDF page image when available.",
876                serde_json::json!({
877                    "type": "object",
878                    "properties": {
879                        "action": { "type": "string", "enum": ["search", "read", "pdf_page"] },
880                        "query": { "type": "string", "description": "search query" },
881                        "name": { "type": "string", "description": "imported file name" },
882                        "offset": { "type": "integer" },
883                        "limit": { "type": "integer", "description": "maximum 200 lines" },
884                        "page": { "type": "integer", "description": "1-based PDF page" }
885                    },
886                    "required": ["action"]
887                }),
888            ));
889        }
890        if self.apps.is_some() {
891            defs.push(tool_def(
892                "app",
893                "Build and manage locally served web apps. action=read returns stable hash-lines for safe editing and action=search greps non-ignored files; action=write replaces complete content, action=patch applies hash-line edits (include the line number when identical lines repeat), and action=diff previews a complete candidate without writing; action=list shows conversation/space images and action=copy_file/copy_images bring user data into an app (images go to _images/, text files to the app KV store); action=init scaffolds a React starter (framework astro = Astro + React islands, vite-react = Vite SPA; default astro) and action=build compiles the app — it installs missing deps from package.json, runs the framework's static build with --base=/<app-uuid>/ (so asset links resolve under the app's URL), and serves the result from dist/ (build errors come back here for you to fix; requires node/npm on this machine).",
894                serde_json::json!({
895                    "type": "object",
896                    "properties": {
897                        "action": { "type": "string", "enum": ["read", "search", "write", "patch", "diff", "list", "copy_file", "copy_images", "init", "build"] },
898                        "app": { "type": "string", "description": "app name or UUID" },
899                        "path": { "type": "string", "description": "file path within the app" },
900                        "pattern": { "type": "string", "description": "case-insensitive search text for search" },
901                        "content": { "type": "string", "description": "complete content for write or diff" },
902                        "edits": { "type": "array", "description": "hash-line edits from the latest read; hashes remain valid when earlier lines move, and include line when identical lines repeat", "items": { "type": "object", "properties": { "hash": { "type": "string", "description": "hash from the latest read" }, "line": { "type": "integer", "description": "optional 1-based line number to disambiguate repeated lines" }, "new": { "type": ["string", "null"] } }, "required": ["hash"] } },
903                        "file_name": { "type": "string", "description": "imported file name for copy_file" },
904                        "image_ids": { "type": "array", "items": { "type": "string" }, "description": "image IDs for copy_images" },
905                        "framework": { "type": "string", "enum": ["astro", "vite-react"], "description": "starter template for init (default astro)" },
906                        "offset": { "type": "integer" },
907                        "limit": { "type": "integer", "description": "maximum 200 lines" },
908                        "compact": { "type": "boolean", "description": "return locations only for search (default true)" }
909                    },
910                    "required": ["action"]
911                }),
912            ));
913        }
914        let mut media_actions = Vec::new();
915        if self.image_gen_backend.is_some() {
916            media_actions.push("generate_image");
917        }
918        if self.video_gen_backend.is_some() {
919            media_actions.extend([
920                "generate_video",
921                "edit",
922                "extract_frame",
923                "stitch",
924                "save_reference",
925                "list_references",
926                "delete_reference",
927            ]);
928        }
929        if !media_actions.is_empty() {
930            defs.push(tool_def(
931                "media",
932                "Generate and transform media. action=generate_image makes an image from a prompt, optionally using a pasted image ID as a reference; action=generate_video makes a video from text and optional frame/reference images; action=edit/extract_frame/stitch transform videos locally with ffmpeg (effects, frame extraction, clip concatenation); action=save_reference/list_references/delete_reference manage named image references used for video consistency.",
933                serde_json::json!({
934                    "type": "object",
935                    "properties": {
936                        "action": { "type": "string", "enum": media_actions },
937                        "prompt": { "type": "string" },
938                        "image_id": { "type": "string", "description": "reference image for generate_image, or image being saved for save_reference" },
939                        "size": { "type": "string", "default": "1024x1024" },
940                        "duration": { "type": "integer" },
941                        "resolution": { "type": "string" },
942                        "aspect_ratio": { "type": "string" },
943                        "generate_audio": { "type": "boolean" },
944                        "first_frame_id": { "type": "string" },
945                        "last_frame_id": { "type": "string" },
946                        "ref_image_id": { "type": "string" },
947                        "character_refs": { "type": "array", "items": { "type": "string" } },
948                        "location_refs": { "type": "array", "items": { "type": "string" } },
949                        "seed": { "type": "integer" },
950                        "source_video_id": { "type": "string" },
951                        "video_id": { "type": "string" },
952                        "video_ids": { "type": "array", "items": { "type": "string" } },
953                        "lighting": { "type": "string", "enum": ["noir", "warm", "cold", "vintage", "vivid", "bleach_bypass"] },
954                        "camera_move": { "type": "string", "enum": ["dolly_in", "dolly_out", "pan_left", "pan_right", "tilt_up", "tilt_down"] },
955                        "intensity": { "type": "number" },
956                        "speed": { "type": "number" },
957                        "trim_start": { "type": "number" },
958                        "trim_end": { "type": "number" },
959                        "remove_audio": { "type": "boolean" },
960                        "time_sec": { "type": "number" },
961                        "format": { "type": "string", "enum": ["png", "jpg"] },
962                        "name": { "type": "string", "description": "reference name for save_reference/delete_reference" },
963                        "description": { "type": "string", "description": "reference description for save_reference" }
964                    },
965                    "required": ["action"]
966                }),
967            ));
968        }
969        if self.research_only {
970            defs.retain(|d| matches!(d.name.as_str(), "search" | "fetch_url"));
971        }
972        defs
973    }
974
975    /// Resolve an app name or UUID to `(uuid, app_dir)`. If a name is given and
976    /// not yet registered, a new UUID is assigned. Returns Err if the app name
977    /// is invalid (not allowed by `resolve_confined` constraints).
978    fn resolve_app(&self, name_or_uuid: &str) -> Result<(String, PathBuf), String> {
979        let ctx = self.apps.as_ref().ok_or("apps are not available")?;
980        let (uuid, app_name) = if looks_like_uuid(name_or_uuid) {
981            let entry = ctx
982                .registry
983                .lookup(name_or_uuid)
984                .ok_or_else(|| format!("unknown app uuid: {name_or_uuid}"))?;
985            (name_or_uuid.to_string(), entry.name)
986        } else if name_or_uuid.is_empty()
987            || name_or_uuid.contains(['/', '\\'])
988            || name_or_uuid == "."
989            || name_or_uuid == ".."
990        {
991            return Err(format!("invalid app name: {name_or_uuid:?}"));
992        } else {
993            let uuid = match ctx.registry.resolve(&ctx.space_name, name_or_uuid) {
994                Some(u) => u,
995                None => ctx.registry.assign(&ctx.space_name, name_or_uuid),
996            };
997            (uuid, name_or_uuid.to_string())
998        };
999        let app_dir = ctx.dir.join(&app_name);
1000        Ok((uuid, app_dir))
1001    }
1002
1003    /// The live URL for an app (accepts a UUID).
1004    fn app_link(&self, uuid: &str) -> String {
1005        match &self.apps {
1006            Some(ctx) => ctx.public_base.as_ref().map_or_else(
1007                || format!("live at http://127.0.0.1:{}/{}/", ctx.server_port, uuid),
1008                |base| format!("live at {}", crate::appserver::public_app_url(base, uuid)),
1009            ),
1010            None => String::new(),
1011        }
1012    }
1013
1014    /// The build tool a package.json declares (dependencies or
1015    /// devDependencies), if any: "astro" or "vite".
1016    #[must_use]
1017    fn declared_build_tool(pkg: &serde_json::Value) -> Option<&'static str> {
1018        let mut deps: Vec<String> = Vec::new();
1019        for key in ["dependencies", "devDependencies"] {
1020            if let Some(obj) = pkg.get(key).and_then(serde_json::Value::as_object) {
1021                deps.extend(obj.keys().cloned());
1022            }
1023        }
1024        if deps.iter().any(|d| d == "astro") {
1025            Some("astro")
1026        } else if deps.iter().any(|d| d == "vite") {
1027            Some("vite")
1028        } else {
1029            None
1030        }
1031    }
1032
1033    /// Run a tool by name. Returns `(result text sent back to the model,
1034    /// status label shown in the UI while it runs)`.
1035    // Long by design: the model tool dispatch (each arm is one tool).
1036    #[allow(clippy::too_many_lines)]
1037    pub async fn run(&self, name: &str, args: &str) -> (String, String) {
1038        if self.research_only
1039            && !matches!(
1040                name,
1041                "search" | "fetch_url" | "web_search" | "academic_search" | "discussion_search"
1042            )
1043        {
1044            return (
1045                format!("tool '{name}' is not available in research mode"),
1046                "blocked".to_string(),
1047            );
1048        }
1049        let (dispatch_name, dispatch_args) = match public_call(name, args) {
1050            Ok(call) => call,
1051            Err(error) => return (cap_tool_result(error), "invalid arguments".to_string()),
1052        };
1053        let name = dispatch_name.as_str();
1054        let args = dispatch_args.as_str();
1055        let (result, status) = match name {
1056            "skill" => {
1057                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1058                let skill_name = v
1059                    .get("name")
1060                    .and_then(|n| n.as_str())
1061                    .unwrap_or_default()
1062                    .to_string();
1063                let file = v
1064                    .get("file")
1065                    .and_then(|f| f.as_str())
1066                    .filter(|f| !f.is_empty())
1067                    .unwrap_or("SKILL.md");
1068                let status = format!("Reading {skill_name}/{file}…");
1069                let result = match resolve_skill_file(&self.skill_dirs, &skill_name, file) {
1070                    Err(e) => e,
1071                    Ok(path) if !path.is_file() => format!("no such file: {skill_name}/{file}"),
1072                    Ok(path) => {
1073                        let text = match std::fs::read_to_string(&path) {
1074                            Ok(t) => t,
1075                            Err(e) => format!("error reading {skill_name}/{file}: {e}"),
1076                        };
1077                        if file == "SKILL.md" {
1078                            skill_body(&text).to_string()
1079                        } else {
1080                            text
1081                        }
1082                    }
1083                };
1084                (result, status)
1085            }
1086            "install_skill" => {
1087                let source = serde_json::from_str::<serde_json::Value>(args)
1088                    .ok()
1089                    .and_then(|v| v.get("source").and_then(|s| s.as_str()).map(str::to_string))
1090                    .unwrap_or_default();
1091                let status = format!("Installing skill {source}…");
1092                let result = match crate::skills::parse_gh_shorthand(&source) {
1093                    None => format!("invalid source {source:?} — expected owner/repo/path"),
1094                    Some((owner, repo, path)) => {
1095                        match crate::skills::install_from_github(
1096                            &self.client,
1097                            &owner,
1098                            &repo,
1099                            &path,
1100                            &self.skills_dir,
1101                        )
1102                        .await
1103                        {
1104                            Ok(name) => {
1105                                format!("installed skill '{name}' — load it with the skill tool")
1106                            }
1107                            Err(e) => format!("install failed: {e}"),
1108                        }
1109                    }
1110                };
1111                (result, status)
1112            }
1113            "create_skill" => {
1114                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1115                let name = v
1116                    .get("name")
1117                    .and_then(|x| x.as_str())
1118                    .unwrap_or_default()
1119                    .to_string();
1120                let description = v
1121                    .get("description")
1122                    .and_then(|x| x.as_str())
1123                    .unwrap_or_default()
1124                    .to_string();
1125                let body = v
1126                    .get("body")
1127                    .and_then(|x| x.as_str())
1128                    .filter(|b| !b.is_empty());
1129                let overwrite = v
1130                    .get("overwrite")
1131                    .and_then(serde_json::Value::as_bool)
1132                    .unwrap_or(false);
1133                let status = format!("Creating skill {name}…");
1134                let result = if !crate::skills::valid_skill_name(&name) {
1135                    "name must be 1-64 lowercase letters, digits, and single hyphens".to_string()
1136                } else if description.is_empty()
1137                    || description.chars().count() > 1_024
1138                    || description.contains(['\n', '\r'])
1139                {
1140                    "description must be 1-1024 characters on one line".to_string()
1141                } else {
1142                    let dir = self.skills_dir.join(&name);
1143                    let existed = dir.exists();
1144                    if existed && !overwrite {
1145                        format!("skill '{name}' already exists — set overwrite=true to replace")
1146                    } else if let Err(e) = std::fs::create_dir_all(&dir) {
1147                        format!("cannot create skill dir: {e}")
1148                    } else {
1149                        let body = body.unwrap_or("Write the skill instructions here. The model sees this text when it loads the skill.");
1150                        let md = format!(
1151                            "---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"
1152                        );
1153                        match std::fs::write(dir.join("SKILL.md"), &md) {
1154                            Ok(()) => {
1155                                let verb = if existed { "updated" } else { "created" };
1156                                format!("{verb} skill '{name}' — load it with the skill tool")
1157                            }
1158                            Err(e) => format!("cannot write SKILL.md: {e}"),
1159                        }
1160                    }
1161                };
1162                (result, status)
1163            }
1164            "run_script" => {
1165                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1166                let field = |k: &str| {
1167                    v.get(k)
1168                        .and_then(|x| x.as_str())
1169                        .unwrap_or_default()
1170                        .to_string()
1171                };
1172                let is_space = v
1173                    .get("space")
1174                    .and_then(serde_json::Value::as_bool)
1175                    .unwrap_or(false);
1176                let (skill, script) = (field("skill"), field("path"));
1177                let extra: Vec<String> = v
1178                    .get("args")
1179                    .and_then(|a| a.as_array())
1180                    .map(|a| {
1181                        a.iter()
1182                            .filter_map(|x| x.as_str().map(str::to_string))
1183                            .collect()
1184                    })
1185                    .unwrap_or_default();
1186                let status = if is_space {
1187                    format!("Running space script {script}…")
1188                } else {
1189                    format!("Running {skill}/{script}…")
1190                };
1191                let result = if is_space {
1192                    let file = self.space_scripts_dir.join(&script);
1193                    if !valid_relative_path(&script) || !file.starts_with(&self.space_scripts_dir) {
1194                        format!("invalid path: {script}")
1195                    } else if !file.is_file() {
1196                        format!("no such script: {script}")
1197                    } else {
1198                        let dir = self.space_scripts_dir.clone();
1199                        let ext = file
1200                            .extension()
1201                            .and_then(|e| e.to_str())
1202                            .unwrap_or("")
1203                            .to_lowercase();
1204                        let run = async {
1205                            let mut cmd: Vec<std::ffi::OsString> = Vec::new();
1206                            let program: std::ffi::OsString = match ext.as_str() {
1207                                "py" => {
1208                                    let py = ensure_venv(&dir).await?;
1209                                    cmd.push(file.clone().into());
1210                                    py.into()
1211                                }
1212                                "sh" | "bash" => {
1213                                    cmd.push(file.clone().into());
1214                                    "bash".into()
1215                                }
1216                                "js" | "mjs" => {
1217                                    cmd.push(file.clone().into());
1218                                    "node".into()
1219                                }
1220                                _ => file.clone().into(),
1221                            };
1222                            cmd.extend(extra.iter().map(std::ffi::OsString::from));
1223                            let refs: Vec<&std::ffi::OsStr> =
1224                                cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1225                            let files_dir = self.space_files_dir.to_string_lossy().to_string();
1226                            let apps_dir = self.space_apps_dir.to_string_lossy().to_string();
1227                            let scripts_dir = self.space_scripts_dir.to_string_lossy().to_string();
1228                            if ext == "py" {
1229                                let pp_dir = dir.join("scripts");
1230                                let pp = pp_dir.to_string_lossy().to_string();
1231                                run_cmd_env(
1232                                    &program,
1233                                    &refs,
1234                                    &dir,
1235                                    120,
1236                                    &[
1237                                        ("SPACE_FILES_DIR", files_dir.as_str()),
1238                                        ("SPACE_APPS_DIR", apps_dir.as_str()),
1239                                        ("SPACE_SCRIPTS_DIR", scripts_dir.as_str()),
1240                                        ("PYTHONPATH", pp.as_str()),
1241                                    ],
1242                                )
1243                                .await
1244                            } else {
1245                                run_cmd_env(
1246                                    &program,
1247                                    &refs,
1248                                    &dir,
1249                                    120,
1250                                    &[
1251                                        ("SPACE_FILES_DIR", files_dir.as_str()),
1252                                        ("SPACE_APPS_DIR", apps_dir.as_str()),
1253                                        ("SPACE_SCRIPTS_DIR", scripts_dir.as_str()),
1254                                    ],
1255                                )
1256                                .await
1257                            }
1258                        };
1259                        match run.await {
1260                            Ok(out) => format_output(&out),
1261                            Err(e) => e,
1262                        }
1263                    }
1264                } else {
1265                    match (
1266                        resolve_skill_dir(&self.skill_dirs, &skill),
1267                        resolve_skill_file(&self.skill_dirs, &skill, &script),
1268                    ) {
1269                        (Err(e), _) | (_, Err(e)) => e,
1270                        (Ok(_dir), Ok(file)) if !file.is_file() => {
1271                            format!("no such script: {skill}/{script}")
1272                        }
1273                        (Ok(dir), Ok(file)) => {
1274                            let ext = file
1275                                .extension()
1276                                .and_then(|e| e.to_str())
1277                                .unwrap_or("")
1278                                .to_lowercase();
1279                            let run = async {
1280                                let mut cmd: Vec<std::ffi::OsString> = Vec::new();
1281                                let program: std::ffi::OsString = match ext.as_str() {
1282                                    "py" => {
1283                                        let py = ensure_venv(&dir).await?;
1284                                        cmd.push(file.clone().into());
1285                                        py.into()
1286                                    }
1287                                    "sh" | "bash" => {
1288                                        cmd.push(file.clone().into());
1289                                        "bash".into()
1290                                    }
1291                                    "js" | "mjs" => {
1292                                        cmd.push(file.clone().into());
1293                                        "node".into()
1294                                    }
1295                                    _ => file.clone().into(),
1296                                };
1297                                cmd.extend(extra.iter().map(std::ffi::OsString::from));
1298                                let refs: Vec<&std::ffi::OsStr> =
1299                                    cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1300                                run_cmd(&program, &refs, &dir, 120).await
1301                            };
1302                            match run.await {
1303                                Ok(out) => format_output(&out),
1304                                Err(e) => e,
1305                            }
1306                        }
1307                    }
1308                };
1309                (result, status)
1310            }
1311            "install_packages" => {
1312                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1313                let field = |k: &str| {
1314                    v.get(k)
1315                        .and_then(|x| x.as_str())
1316                        .unwrap_or_default()
1317                        .to_string()
1318                };
1319                let pkgs: Vec<String> = v
1320                    .get("packages")
1321                    .and_then(|a| a.as_array())
1322                    .map(|a| {
1323                        a.iter()
1324                            .filter_map(|x| x.as_str().map(str::to_string))
1325                            .collect()
1326                    })
1327                    .unwrap_or_default();
1328                let (skill, app) = (field("skill"), field("app"));
1329                let status = format!("Installing {}…", pkgs.join(" "));
1330                let result = match validate_packages(&pkgs) {
1331                    Err(e) => e,
1332                    Ok(()) if !skill.is_empty() && !app.is_empty() => {
1333                        "pass either skill or app, not both".to_string()
1334                    }
1335                    Ok(()) if !skill.is_empty() => {
1336                        match resolve_skill_dir(&self.skill_dirs, &skill) {
1337                            Err(_) => format!("unknown skill: {skill}"),
1338                            Ok(dir) if !dir.join("SKILL.md").is_file() => {
1339                                format!("unknown skill: {skill}")
1340                            }
1341                            Ok(dir) => {
1342                                let run = async {
1343                                    let py = ensure_venv(&dir).await?;
1344                                    let mut cmd: Vec<std::ffi::OsString> =
1345                                        vec!["-m".into(), "pip".into(), "install".into()];
1346                                    cmd.extend(pkgs.iter().map(std::ffi::OsString::from));
1347                                    let refs: Vec<&std::ffi::OsStr> =
1348                                        cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1349                                    run_cmd(py.as_os_str(), &refs, &dir, 300).await
1350                                };
1351                                match run.await {
1352                                    Ok(out) if out.status.success() => {
1353                                        format!("installed {} into {skill}'s venv", pkgs.join(" "))
1354                                    }
1355                                    Ok(out) => {
1356                                        format!("pip install failed:\n{}", format_output(&out))
1357                                    }
1358                                    Err(e) => e,
1359                                }
1360                            }
1361                        }
1362                    }
1363                    Ok(()) if !app.is_empty() => match self.resolve_app(&app) {
1364                        Err(e) => e,
1365                        Ok((uuid, app_dir)) => {
1366                            let pkg_json = app_dir.join("package.json");
1367                            let prep = std::fs::create_dir_all(&app_dir).and_then(|()| {
1368                                if pkg_json.exists() {
1369                                    Ok(())
1370                                } else {
1371                                    // npm walks up looking for a package.json — pin
1372                                    // the install to this app dir with a minimal one.
1373                                    std::fs::write(
1374                                        &pkg_json,
1375                                        format!("{{\"name\":{app:?},\"private\":true}}"),
1376                                    )
1377                                }
1378                            });
1379                            if let Err(e) = prep {
1380                                format!("cannot prepare {app}: {e}")
1381                            } else {
1382                                let mut cmd: Vec<std::ffi::OsString> =
1383                                    vec!["install".into(), "--no-audit".into(), "--no-fund".into()];
1384                                cmd.extend(pkgs.iter().map(std::ffi::OsString::from));
1385                                let refs: Vec<&std::ffi::OsStr> =
1386                                    cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1387                                match run_cmd("npm".as_ref(), &refs, &app_dir, 300).await {
1388                                    Ok(out) if out.status.success() => format!(
1389                                        "installed {} into {app}/node_modules — reference files as node_modules/<pkg>/… ; {}",
1390                                        pkgs.join(" "),
1391                                        self.app_link(&uuid),
1392                                    ),
1393                                    Ok(out) => {
1394                                        format!("npm install failed:\n{}", format_output(&out))
1395                                    }
1396                                    Err(e) => e,
1397                                }
1398                            }
1399                        }
1400                    },
1401                    Ok(()) => {
1402                        // No target: the space scripts dir venv (shared with run_python).
1403                        let dir = self.space_scripts_dir.clone();
1404                        let run = async {
1405                            std::fs::create_dir_all(&dir)
1406                                .map_err(|e| format!("cannot create scripts dir: {e}"))?;
1407                            let py = ensure_venv(&dir).await?;
1408                            let mut cmd: Vec<std::ffi::OsString> =
1409                                vec!["-m".into(), "pip".into(), "install".into()];
1410                            cmd.extend(pkgs.iter().map(std::ffi::OsString::from));
1411                            let refs: Vec<&std::ffi::OsStr> =
1412                                cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1413                            run_cmd(py.as_os_str(), &refs, &dir, 300).await
1414                        };
1415                        match run.await {
1416                            Ok(out) if out.status.success() => {
1417                                format!("installed {} into the python scripts venv", pkgs.join(" "))
1418                            }
1419                            Ok(out) => format!("pip install failed:\n{}", format_output(&out)),
1420                            Err(e) => e,
1421                        }
1422                    }
1423                };
1424                (result, status)
1425            }
1426            "run_python" => {
1427                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1428                let code = v
1429                    .get("code")
1430                    .and_then(|x| x.as_str())
1431                    .unwrap_or_default()
1432                    .to_string();
1433                let name = v
1434                    .get("name")
1435                    .and_then(|n| n.as_str())
1436                    .filter(|n| !n.is_empty())
1437                    .map(str::to_string);
1438                let temporary = v
1439                    .get("temporary")
1440                    .and_then(serde_json::Value::as_bool)
1441                    .unwrap_or(false);
1442                let extra: Vec<String> = v
1443                    .get("args")
1444                    .and_then(|a| a.as_array())
1445                    .map(|a| {
1446                        a.iter()
1447                            .filter_map(|x| x.as_str().map(str::to_string))
1448                            .collect()
1449                    })
1450                    .unwrap_or_default();
1451                let result = match name {
1452                    None => "run_python requires a `name` parameter".to_string(),
1453                    Some(ref name) if !valid_relative_path(name) => {
1454                        format!("invalid script path: {name}")
1455                    }
1456                    Some(ref name) if !name.to_lowercase().ends_with(".py") => {
1457                        "run_python name must end with .py".to_string()
1458                    }
1459                    Some(ref name) => {
1460                        let (dir, file) = if temporary {
1461                            let tmp = std::env::temp_dir()
1462                                .join(format!("nexus-script-{}", uuid::Uuid::new_v4()));
1463                            let f = tmp.join(name);
1464                            (tmp, f)
1465                        } else {
1466                            let d = self.space_scripts_dir.clone();
1467                            let f = d.join(name);
1468                            (d, f)
1469                        };
1470                        let run = async {
1471                            if code.trim().is_empty() {
1472                                return Err("code must not be empty".to_string());
1473                            }
1474                            std::fs::create_dir_all(&dir)
1475                                .map_err(|e| format!("cannot create dir: {e}"))?;
1476                            std::fs::write(&file, &code)
1477                                .map_err(|e| format!("cannot write script: {e}"))?;
1478                            let py = ensure_venv(&dir).await?;
1479                            let mut cmd: Vec<std::ffi::OsString> = vec![file.into()];
1480                            cmd.extend(extra.iter().map(std::ffi::OsString::from));
1481                            let refs: Vec<&std::ffi::OsStr> =
1482                                cmd.iter().map(std::ffi::OsString::as_os_str).collect();
1483                            let files_dir = self.space_files_dir.to_string_lossy().to_string();
1484                            let apps_dir = self.space_apps_dir.to_string_lossy().to_string();
1485                            let scripts_dir = self.space_scripts_dir.to_string_lossy().to_string();
1486                            let envs = &[
1487                                ("SPACE_FILES_DIR", files_dir.as_str()),
1488                                ("SPACE_APPS_DIR", apps_dir.as_str()),
1489                                ("SPACE_SCRIPTS_DIR", scripts_dir.as_str()),
1490                            ];
1491                            run_cmd_env(py.as_os_str(), &refs, &dir, 120, envs).await
1492                        };
1493                        let output = run.await;
1494                        if temporary {
1495                            let _ = std::fs::remove_dir_all(&dir);
1496                        }
1497                        match output {
1498                            Ok(out) => format_output(&out),
1499                            Err(e) => e,
1500                        }
1501                    }
1502                };
1503                (result, "Running script…".to_string())
1504            }
1505            "web_search" => {
1506                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1507                let query = v
1508                    .get("query")
1509                    .and_then(|q| q.as_str())
1510                    .unwrap_or_default()
1511                    .to_string();
1512                let recency = v
1513                    .get("recency")
1514                    .and_then(|r| r.as_str())
1515                    .map(str::to_string);
1516                let str_list = |k: &str| {
1517                    v.get(k)
1518                        .and_then(|a| a.as_array())
1519                        .map(|a| {
1520                            a.iter()
1521                                .filter_map(|x| x.as_str().map(str::to_string))
1522                                .collect::<Vec<_>>()
1523                        })
1524                        .unwrap_or_default()
1525                };
1526                let include = str_list("include_domains");
1527                let mut exclude = str_list("exclude_domains");
1528                exclude.extend(self.blocked_domains.iter().cloned());
1529                let status = "Searching the web…".to_string();
1530                let result = match self
1531                    .search(&query, recency.as_deref(), &include, &exclude)
1532                    .await
1533                {
1534                    Ok(hits) if hits.is_empty() => "no results".to_string(),
1535                    Ok(hits) => format_results(&hits),
1536                    Err(e) => format!("search failed: {e}"),
1537                };
1538                (result, status)
1539            }
1540            "fetch_url" => {
1541                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1542                let url = v
1543                    .get("url")
1544                    .and_then(|u| u.as_str())
1545                    .unwrap_or_default()
1546                    .to_string();
1547                let offset = usize::try_from(
1548                    v.get("offset")
1549                        .and_then(serde_json::Value::as_u64)
1550                        .unwrap_or(1)
1551                        .max(1),
1552                )
1553                .unwrap_or(1);
1554                let limit = v
1555                    .get("limit")
1556                    .and_then(serde_json::Value::as_u64)
1557                    .unwrap_or(200)
1558                    .clamp(1, 200) as usize;
1559                let fresh = v
1560                    .get("fresh")
1561                    .and_then(serde_json::Value::as_bool)
1562                    .unwrap_or(false);
1563                let status = format!("Fetching {url}…");
1564                let result = match self.fetch_cached(&url, fresh).await {
1565                    Ok(text) => {
1566                        let lines: Vec<&str> = text.lines().collect();
1567                        let total = lines.len();
1568                        let start = (offset - 1).min(total);
1569                        let slice = &lines[start..(start + limit).min(total)];
1570                        if slice.is_empty() {
1571                            format!("{url}: offset {offset} is past the end ({total} lines)")
1572                        } else {
1573                            format!(
1574                                "{url} (lines {}-{} of {total}):\n{}",
1575                                start + 1,
1576                                start + slice.len(),
1577                                number_lines(slice, start),
1578                            )
1579                        }
1580                    }
1581                    Err(e) => format!("fetch failed: {e}"),
1582                };
1583                (result, status)
1584            }
1585            "academic_search" => {
1586                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1587                let query = v
1588                    .get("query")
1589                    .and_then(|q| q.as_str())
1590                    .unwrap_or_default()
1591                    .to_string();
1592                let limit = usize::try_from(
1593                    v.get("limit")
1594                        .and_then(serde_json::Value::as_u64)
1595                        .unwrap_or(10),
1596                )
1597                .unwrap_or(10);
1598                let status = "Searching academic literature…".to_string();
1599                let result = match academic_search(&self.client, &query, limit).await {
1600                    Ok(papers) if papers.is_empty() => "no results".to_string(),
1601                    Ok(papers) => format_papers(&papers),
1602                    Err(e) => format!("academic search failed: {e}"),
1603                };
1604                (result, status)
1605            }
1606            "discussion_search" => {
1607                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1608                let query = v
1609                    .get("query")
1610                    .and_then(|q| q.as_str())
1611                    .unwrap_or_default()
1612                    .to_string();
1613                let status = "Searching HN and Reddit…".to_string();
1614                let cache_key = format!("discussion://{query}");
1615
1616                // Check cache first if enabled
1617                let cached = if let Some(db_path) = &self.db_path {
1618                    crate::db::open_attached(db_path)
1619                        .ok()
1620                        .and_then(|conn| crate::db::cache_get(&conn, &cache_key).ok().flatten())
1621                        .and_then(|(_, text, fetched_at)| {
1622                            // Do not preserve the old empty-result sentinel:
1623                            // it may have been produced by a blocked backend,
1624                            // and a later fallback can now recover results.
1625                            if crate::db::is_fresh(&fetched_at, chrono::Utc::now())
1626                                && text != "no results"
1627                            {
1628                                Some(text)
1629                            } else {
1630                                None
1631                            }
1632                        })
1633                } else {
1634                    None
1635                };
1636
1637                let result = if let Some(cached_text) = cached {
1638                    cached_text
1639                } else {
1640                    let text = discussion_search(&self.client, &query).await;
1641                    // Write through to cache if enabled
1642                    if text != "no results"
1643                        && let Some(db_path) = &self.db_path
1644                        && let Ok(conn) = crate::db::open_attached(db_path)
1645                    {
1646                        let _ = crate::db::cache_put(&conn, &cache_key, &cache_key, None, &text);
1647                    }
1648                    text
1649                };
1650                (result, status)
1651            }
1652            "search_sources" => {
1653                let query = serde_json::from_str::<serde_json::Value>(args)
1654                    .ok()
1655                    .and_then(|v| v.get("query").and_then(|q| q.as_str()).map(str::to_string))
1656                    .unwrap_or_default();
1657                let status = "Searching session sources…".to_string();
1658                let result = match (&self.research_session_id, &self.db_path) {
1659                    (Some(session_id), Some(db_path)) => match crate::db::open_attached(db_path) {
1660                        Err(e) => format!("source search failed: {e}"),
1661                        Ok(conn) => {
1662                            match crate::db::search_session_sources(&conn, session_id, &query) {
1663                                Ok(hits) if hits.is_empty() => {
1664                                    "no matches in this session's sources".to_string()
1665                                }
1666                                Ok(hits) => hits
1667                                    .iter()
1668                                    .map(|(url, text)| {
1669                                        let cut: String = text.chars().take(500).collect();
1670                                        format!("{url}:\n{cut}")
1671                                    })
1672                                    .collect::<Vec<_>>()
1673                                    .join("\n\n"),
1674                                Err(e) => format!("source search failed: {e}"),
1675                            }
1676                        }
1677                    },
1678                    _ => "no session source bundle available".to_string(),
1679                };
1680                (result, status)
1681            }
1682            "list_citations" => {
1683                let query = serde_json::from_str::<serde_json::Value>(args)
1684                    .ok()
1685                    .and_then(|v| v.get("query").and_then(|q| q.as_str()).map(str::to_string));
1686                let status = "Listing citations…".to_string();
1687                let result = match &self.files {
1688                    None => "no space context available".to_string(),
1689                    Some(ctx) => match rusqlite::Connection::open(&ctx.db_path) {
1690                        Err(e) => format!("citation lookup failed: {e}"),
1691                        Ok(conn) => match crate::db::search_citations(
1692                            &conn,
1693                            &ctx.space_id,
1694                            query.as_deref(),
1695                        ) {
1696                            Ok(rows) if rows.is_empty() => "no citations recorded yet".to_string(),
1697                            Ok(rows) => rows
1698                                .iter()
1699                                .map(|(report, url, title)| {
1700                                    if title.is_empty() {
1701                                        format!("{report}: {url}")
1702                                    } else {
1703                                        format!("{report}: {url} ({title})")
1704                                    }
1705                                })
1706                                .collect::<Vec<_>>()
1707                                .join("\n"),
1708                            Err(e) => format!("citation lookup failed: {e}"),
1709                        },
1710                    },
1711                };
1712                (result, status)
1713            }
1714            "batch" => {
1715                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1716                let calls: Vec<(String, serde_json::Value)> = v
1717                    .get("calls")
1718                    .and_then(|c| c.as_array())
1719                    .map(|arr| {
1720                        arr.iter()
1721                            .filter_map(|item| {
1722                                let tool = item
1723                                    .get("tool")
1724                                    .and_then(|t| t.as_str())
1725                                    .filter(|t| !t.is_empty())?
1726                                    .to_string();
1727                                let arguments = item
1728                                    .get("arguments")
1729                                    .cloned()
1730                                    .unwrap_or_else(|| serde_json::json!({}));
1731                                Some((tool, arguments))
1732                            })
1733                            .collect()
1734                    })
1735                    .unwrap_or_default();
1736                if calls.is_empty() {
1737                    return (
1738                        "batch requires a non-empty calls array".to_string(),
1739                        "Running batch…".to_string(),
1740                    );
1741                }
1742                if calls.len() > MAX_BATCH_CALLS {
1743                    return (
1744                        format!(
1745                            "batch accepts at most {MAX_BATCH_CALLS} calls, got {}",
1746                            calls.len()
1747                        ),
1748                        "Running batch…".to_string(),
1749                    );
1750                }
1751                if calls.iter().any(|(tool, _)| tool == "batch") {
1752                    return (
1753                        "nested batch calls are not allowed — flatten them into one list"
1754                            .to_string(),
1755                        "Running batch…".to_string(),
1756                    );
1757                }
1758                let status = format!("Running {} batched operations…", calls.len());
1759                // Serialize each sub-call once. Read-only batches run
1760                // concurrently (network latency overlaps); any mutating call
1761                // keeps the whole batch sequential so writes to the same file
1762                // can't race. Results are zipped back into call order.
1763                let items: Vec<(String, String)> = calls
1764                    .iter()
1765                    .map(|(tool, arguments)| {
1766                        (
1767                            tool.clone(),
1768                            serde_json::to_string(arguments).unwrap_or_else(|_| "{}".to_string()),
1769                        )
1770                    })
1771                    .collect();
1772                let results: Vec<(String, String)> = if items.len() > 1
1773                    && items
1774                        .iter()
1775                        .all(|(tool, args)| is_read_only_tool(tool, args))
1776                {
1777                    // Box::pin keeps the recursive `run` future behind a
1778                    // pointer so the join_all future stays finitely sized.
1779                    futures_util::future::join_all(
1780                        items
1781                            .iter()
1782                            .map(|(tool, args)| Box::pin(self.run(tool, args))),
1783                    )
1784                    .await
1785                } else {
1786                    let mut results = Vec::with_capacity(items.len());
1787                    for (tool, args) in &items {
1788                        results.push(Box::pin(self.run(tool, args)).await);
1789                    }
1790                    results
1791                };
1792                let mut out = String::new();
1793                for (i, ((tool, _), (_, arguments))) in items.iter().zip(calls.iter()).enumerate() {
1794                    if i > 0 {
1795                        out.push('\n');
1796                    }
1797                    let label: String = batch_call_label(tool, arguments)
1798                        .chars()
1799                        .take(120)
1800                        .collect();
1801                    let _ = write!(
1802                        out,
1803                        "[{}/{}] {}\n{}",
1804                        i + 1,
1805                        items.len(),
1806                        label,
1807                        results[i].0,
1808                    );
1809                }
1810                (out, status)
1811            }
1812            "search_files" => {
1813                let query = serde_json::from_str::<serde_json::Value>(args)
1814                    .ok()
1815                    .and_then(|v| v.get("query").and_then(|q| q.as_str()).map(str::to_string))
1816                    .unwrap_or_default();
1817                let status = "Searching files…".to_string();
1818                let result = match &self.files {
1819                    None => "no files imported".to_string(),
1820                    Some(ctx) => search_files_impl(ctx, &query).await,
1821                };
1822                (result, status)
1823            }
1824            "read_file" => {
1825                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1826                let name = v
1827                    .get("name")
1828                    .and_then(|n| n.as_str())
1829                    .unwrap_or_default()
1830                    .to_string();
1831                let offset = usize::try_from(
1832                    v.get("offset")
1833                        .and_then(serde_json::Value::as_u64)
1834                        .unwrap_or(1)
1835                        .max(1),
1836                )
1837                .unwrap_or(1);
1838                let limit = v
1839                    .get("limit")
1840                    .and_then(serde_json::Value::as_u64)
1841                    .unwrap_or(200)
1842                    .clamp(1, 200) as usize;
1843                let status = format!("Reading {name}…");
1844                let result = match &self.files {
1845                    None => "no files imported".to_string(),
1846                    Some(ctx) => match crate::db::open_attached(&ctx.db_path)
1847                        .and_then(|conn| crate::db::file_text(&conn, &ctx.space_id, &name))
1848                    {
1849                        Ok(Some(text)) => {
1850                            let lines: Vec<&str> = text.lines().collect();
1851                            let total = lines.len();
1852                            let start = (offset - 1).min(total);
1853                            let slice = &lines[start..(start + limit).min(total)];
1854                            if slice.is_empty() {
1855                                format!("{name}: offset {offset} is past the end ({total} lines)")
1856                            } else {
1857                                format!(
1858                                    "{name} (lines {}-{} of {total}):\n{}",
1859                                    start + 1,
1860                                    start + slice.len(),
1861                                    number_lines(slice, start),
1862                                )
1863                            }
1864                        }
1865                        Ok(None) => format!("unknown file: {name}"),
1866                        Err(e) => format!("file read failed: {e}"),
1867                    },
1868                };
1869                (result, status)
1870            }
1871            "read_pdf_page" => {
1872                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1873                let name = v
1874                    .get("name")
1875                    .and_then(|n| n.as_str())
1876                    .unwrap_or_default()
1877                    .to_string();
1878                let page = v
1879                    .get("page")
1880                    .and_then(serde_json::Value::as_u64)
1881                    .unwrap_or(1)
1882                    .max(1);
1883                let status = format!("Reading {name} page {page}…");
1884                let result = match &self.files {
1885                    None => "no files imported".to_string(),
1886                    Some(ctx) => {
1887                        // Verify the PDF is known via DB lookup
1888                        let known = crate::db::open_attached(&ctx.db_path)
1889                            .ok()
1890                            .and_then(|conn| {
1891                                crate::db::file_text(&conn, &ctx.space_id, &name)
1892                                    .ok()
1893                                    .flatten()
1894                            })
1895                            .is_some();
1896                        if !known {
1897                            format!("unknown file: {name}")
1898                        } else if !name.to_lowercase().ends_with(".pdf") {
1899                            format!("not a PDF: {name}")
1900                        } else {
1901                            let stem = std::path::Path::new(&name)
1902                                .file_stem()
1903                                .and_then(|s| s.to_str())
1904                                .unwrap_or(&name);
1905                            let png_path = self
1906                                .space_files_dir
1907                                .join(stem)
1908                                .join(format!("page-{page}.png"));
1909                            if png_path.exists() {
1910                                format!("![page {page}]({stem}/page-{page}.png)")
1911                            } else {
1912                                format!(
1913                                    "page {page} image not available — the PDF was not imported through the vision OCR path. Use files with action=read to read its text content."
1914                                )
1915                            }
1916                        }
1917                    }
1918                };
1919                (result, status)
1920            }
1921            "copy_file_to_app" => {
1922                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
1923                let file_name = v
1924                    .get("file_name")
1925                    .and_then(|x| x.as_str())
1926                    .unwrap_or_default()
1927                    .to_string();
1928                let app = v
1929                    .get("app")
1930                    .and_then(|x| x.as_str())
1931                    .unwrap_or_default()
1932                    .to_string();
1933                let status = format!("Copying {file_name} to {app}…");
1934                let result = match (&self.apps, &self.files) {
1935                    (None, _) => "apps not available".to_string(),
1936                    (_, None) => "files not available".to_string(),
1937                    (Some(ctx), Some(fc)) => {
1938                        let (uuid, app_dir) = match self.resolve_app(&app) {
1939                            Err(e) => return (e, status),
1940                            Ok(t) => t,
1941                        };
1942                        let conn = match crate::db::open_attached(&fc.db_path) {
1943                            Err(e) => return (format!("db error: {e}"), status),
1944                            Ok(c) => c,
1945                        };
1946                        let text = match crate::db::file_text(&conn, &fc.space_id, &file_name) {
1947                            Err(e) => return (format!("file read error: {e}"), status),
1948                            Ok(None) => return (format!("unknown file: {file_name}"), status),
1949                            Ok(Some(t)) => t,
1950                        };
1951                        let store_path = app_dir.join("_store.db");
1952                        let store = match rusqlite::Connection::open(&store_path) {
1953                            Err(e) => return (format!("store error: {e}"), status),
1954                            Ok(s) => s,
1955                        };
1956                        let _ = store.execute_batch(
1957                            "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)",
1958                        );
1959                        let key = format!("_file:{file_name}");
1960                        match store.execute(
1961                            "INSERT OR REPLACE INTO kv (key, value) VALUES (?1, ?2)",
1962                            rusqlite::params![key, text],
1963                        ) {
1964                            Ok(_) => {
1965                                let url = format!("http://127.0.0.1:{}/{uuid}/", ctx.server_port);
1966                                format!(
1967                                    "copied {file_name} into {app}'s KV — read it at {url}_api/kv/_file:{file_name}"
1968                                )
1969                            }
1970                            Err(e) => format!("kv write error: {e}"),
1971                        }
1972                    }
1973                };
1974                (result, status)
1975            }
1976            "list_images" => {
1977                let status = "Listing images…".to_string();
1978                let result = match &self.apps {
1979                    None => "apps not available".to_string(),
1980                    Some(ctx) => {
1981                        let conn = match rusqlite::Connection::open(&ctx.space_db_path) {
1982                            Err(e) => return (format!("db error: {e}"), status),
1983                            Ok(c) => c,
1984                        };
1985                        // Scan messages for markdown image references
1986                        let mut images: Vec<serde_json::Value> = Vec::new();
1987                        let mut stmt = match conn.prepare(
1988                            "SELECT content FROM messages WHERE session_id = ?1 ORDER BY created_at ASC"
1989                        ) {
1990                            Ok(s) => s,
1991                            Err(e) => return (format!("query error: {e}"), status),
1992                        };
1993                        if let Ok(rows) =
1994                            stmt.query_map([&ctx.session_id], |r| r.get::<_, String>(0))
1995                        {
1996                            for row in rows.flatten() {
1997                                let mut rest = row.as_str();
1998                                while let Some(start) = rest.find("![") {
1999                                    if let Some(end) = rest[start..].find(')') {
2000                                        let inner = &rest[start + 2..start + end];
2001                                        if let Some((desc, file)) = inner.split_once("](") {
2002                                            let file = file.to_string();
2003                                            images.push(serde_json::json!({
2004                                                "id": file,
2005                                                "description": desc,
2006                                                "source": "conversation",
2007                                            }));
2008                                        }
2009                                        rest = &rest[start + end + 1..];
2010                                    } else {
2011                                        break;
2012                                    }
2013                                }
2014                            }
2015                        }
2016                        // Include space-file images
2017                        if let Ok(mut fstmt) = conn.prepare(
2018                            "SELECT f.name, f.status FROM files f
2019                             WHERE f.space_id = ?1
2020                             AND (f.name LIKE '%.jpg' OR f.name LIKE '%.jpeg'
2021                               OR f.name LIKE '%.png' OR f.name LIKE '%.gif'
2022                               OR f.name LIKE '%.webp' OR f.name LIKE '%.bmp')",
2023                        )
2024                            && let Ok(rows) = fstmt.query_map([&ctx.space_id], |r| {
2025                                let name: String = r.get(0)?;
2026                                let status: String = r.get(1)?;
2027                                Ok(serde_json::json!({"id": name, "description": null, "source": "space", "status": status}))
2028                            }) {
2029                                images.extend(rows.filter_map(std::result::Result::ok));
2030                            }
2031                        serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string())
2032                    }
2033                };
2034                (result, status)
2035            }
2036            "copy_images_to_app" => {
2037                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2038                let image_ids: Vec<String> = v
2039                    .get("image_ids")
2040                    .and_then(|a| a.as_array())
2041                    .map(|a| {
2042                        a.iter()
2043                            .filter_map(|x| x.as_str().map(str::to_string))
2044                            .collect()
2045                    })
2046                    .unwrap_or_default();
2047                let app = v
2048                    .get("app")
2049                    .and_then(|x| x.as_str())
2050                    .unwrap_or_default()
2051                    .to_string();
2052                let status = format!("Copying {} images to {app}…", image_ids.len());
2053                let result = match self.apps.as_ref() {
2054                    None => "apps not available".to_string(),
2055                    Some(ctx) => {
2056                        let (uuid, app_dir) = match self.resolve_app(&app) {
2057                            Err(e) => return (e, status),
2058                            Ok(t) => t,
2059                        };
2060                        let images_dir = app_dir.join("_images");
2061                        if let Err(e) = std::fs::create_dir_all(&images_dir) {
2062                            return (format!("cannot create _images dir: {e}"), status);
2063                        }
2064                        let mut out: Vec<serde_json::Value> = Vec::new();
2065                        for img_id in &image_ids {
2066                            let src = ctx.files_dir.join(img_id);
2067                            let dst = images_dir.join(img_id);
2068                            if !valid_relative_path(img_id)
2069                                || !src.starts_with(&ctx.files_dir)
2070                                || !dst.starts_with(&images_dir)
2071                                || !src.exists()
2072                            {
2073                                out.push(serde_json::json!({"id": img_id, "error": "not found in space files"}));
2074                                continue;
2075                            }
2076                            match std::fs::copy(&src, &dst) {
2077                                Ok(_) => {
2078                                    out.push(serde_json::json!({
2079                                        "id": img_id,
2080                                        "url": format!("/{uuid}/_images/{img_id}"),
2081                                    }));
2082                                }
2083                                Err(e) => {
2084                                    out.push(
2085                                        serde_json::json!({"id": img_id, "error": format!("{e}")}),
2086                                    );
2087                                }
2088                            }
2089                        }
2090                        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
2091                    }
2092                };
2093                (result, status)
2094            }
2095            "init_app" => {
2096                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2097                let app = v
2098                    .get("app")
2099                    .and_then(|x| x.as_str())
2100                    .unwrap_or_default()
2101                    .to_string();
2102                let framework = v
2103                    .get("framework")
2104                    .and_then(|x| x.as_str())
2105                    .unwrap_or("astro");
2106                let status = format!("Scaffolding {app} ({framework})…");
2107                let result = match self.resolve_app(&app) {
2108                    Err(e) => e,
2109                    Ok((uuid, app_dir)) => {
2110                        match crate::app_templates::scaffold(&app_dir, framework) {
2111                            Err(e) => e,
2112                            Ok(files) => format!(
2113                                "scaffolded {app} with the {framework} starter ({}) — edit with read/patch/write, then `app action=build` (installs deps + compiles to dist/). {}",
2114                                files.join(", "),
2115                                self.app_link(&uuid),
2116                            ),
2117                        }
2118                    }
2119                };
2120                (result, status)
2121            }
2122            "build_app" => {
2123                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2124                let app = v
2125                    .get("app")
2126                    .and_then(|x| x.as_str())
2127                    .unwrap_or_default()
2128                    .to_string();
2129                let status = format!("Building {app}…");
2130                let result = match self.resolve_app(&app) {
2131                    Err(e) => e,
2132                    Ok((uuid, app_dir)) => {
2133                        let pkg_json = app_dir.join("package.json");
2134                        let Some(pkg) = std::fs::read_to_string(&pkg_json)
2135                            .ok()
2136                            .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
2137                        else {
2138                            return (
2139                                "no package.json — write one (or `app action=init` to scaffold a React/Astro or React/Vite app) before building".to_string(),
2140                                status,
2141                            );
2142                        };
2143                        let Some(tool) = Self::declared_build_tool(&pkg) else {
2144                            return (
2145                                "no build step detected (package.json declares neither astro nor vite) — the app is served as-is".to_string(),
2146                                status,
2147                            );
2148                        };
2149                        // Deps declared but not installed: `npm install` from
2150                        // package.json first, so init → build just works.
2151                        let mut npm_err: Option<String> = None;
2152                        let bin = app_dir.join("node_modules").join(".bin").join(tool);
2153                        if !bin.exists() {
2154                            let refs: Vec<&std::ffi::OsStr> =
2155                                ["install", "--no-audit", "--no-fund"]
2156                                    .iter()
2157                                    .map(std::ffi::OsStr::new)
2158                                    .collect();
2159                            match run_cmd("npm".as_ref(), &refs, &app_dir, 600).await {
2160                                Ok(out) if out.status.success() => {}
2161                                Ok(out) => npm_err = Some(format_output(&out)),
2162                                Err(e) => npm_err = Some(e),
2163                            }
2164                        }
2165                        if let Some(err) = npm_err {
2166                            return (
2167                                format!(
2168                                    "cannot build {app}: deps from package.json are missing and `npm install` failed:\n{err}"
2169                                ),
2170                                status,
2171                            );
2172                        }
2173                        // Absolute base: the app is served at /<uuid>/ — pass it
2174                        // on the CLI so asset links resolve regardless of the
2175                        // framework's config (relative bases are unreliable).
2176                        let base_arg = format!("/{uuid}/");
2177                        let build_args =
2178                            ["--no-install", tool, "build", "--base", base_arg.as_str()];
2179                        let refs: Vec<&std::ffi::OsStr> =
2180                            build_args.iter().map(std::ffi::OsStr::new).collect();
2181                        match run_cmd("npx".as_ref(), &refs, &app_dir, 600).await {
2182                            Ok(out) if out.status.success() => {
2183                                // Only flip the served dir when the build
2184                                // really produced dist/ — a framework whose
2185                                // outDir is configured elsewhere exits 0
2186                                // without it, and serving dist/ then would
2187                                // 404 the whole app.
2188                                if !app_dir.join("dist").is_dir() {
2189                                    return (
2190                                        "build reported success but produced no dist/ — the \
2191framework's output directory is configured elsewhere (outDir/base); build output \
2192must land in dist/ for the app server to serve it"
2193                                            .to_string(),
2194                                        status,
2195                                    );
2196                                }
2197                                if let Some(ctx) = &self.apps {
2198                                    ctx.registry.set_served_from(&uuid, "dist");
2199                                }
2200                                format!("build ok — dist/ is live at {}", self.app_link(&uuid))
2201                            }
2202                            Ok(out) => format!("build failed:\n{}", format_output(&out)),
2203                            Err(e) => e,
2204                        }
2205                    }
2206                };
2207                (result, status)
2208            }
2209            "write_file" => {
2210                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2211                let field = |k: &str| {
2212                    v.get(k)
2213                        .and_then(|x| x.as_str())
2214                        .unwrap_or_default()
2215                        .to_string()
2216                };
2217                let (app, path, content) = (field("app"), field("path"), field("content"));
2218                let status = format!("Writing {app}/{path}…");
2219                let result = match self.resolve_app(&app) {
2220                    Err(e) => e,
2221                    Ok((uuid, app_dir)) => {
2222                        let file = app_dir.join(&path);
2223                        if path.is_empty() || path.starts_with('/') || path.contains("..") {
2224                            format!("invalid path: {path:?}")
2225                        } else {
2226                            let write = file
2227                                .parent()
2228                                .map_or(Ok(()), std::fs::create_dir_all)
2229                                .and_then(|()| std::fs::write(&file, &content));
2230                            match write {
2231                                Ok(()) => format!(
2232                                    "wrote {app}/{path} ({} bytes) — {}",
2233                                    content.len(),
2234                                    self.app_link(&uuid),
2235                                ),
2236                                Err(e) => format!("write failed: {e}"),
2237                            }
2238                        }
2239                    }
2240                };
2241                (result, status)
2242            }
2243            "edit_file" => {
2244                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2245                let field = |k: &str| {
2246                    v.get(k)
2247                        .and_then(|x| x.as_str())
2248                        .unwrap_or_default()
2249                        .to_string()
2250                };
2251                let (app, path) = (field("app"), field("path"));
2252                let edits: Vec<(String, Option<usize>, Option<String>)> = v
2253                    .get("edits")
2254                    .and_then(|e| e.as_array())
2255                    .map(|arr| {
2256                        arr.iter()
2257                            .filter_map(|e| {
2258                                let hash = e.get("hash")?.as_str()?.to_string();
2259                                let line = e
2260                                    .get("line")
2261                                    .and_then(serde_json::Value::as_u64)
2262                                    .and_then(|n| usize::try_from(n).ok());
2263                                let new = e.get("new").and_then(|n| n.as_str()).map(str::to_string);
2264                                Some((hash, line, new))
2265                            })
2266                            .collect()
2267                    })
2268                    .unwrap_or_default();
2269                let status = format!("Editing {app}/{path}…");
2270                let result = match self.resolve_app(&app) {
2271                    Err(e) => e,
2272                    Ok((uuid, app_dir)) => {
2273                        let file = app_dir.join(&path);
2274                        if path.is_empty() || path.starts_with('/') || path.contains("..") {
2275                            format!("invalid path: {path:?}")
2276                        } else if app_path_ignored(&app_dir, &file) {
2277                            format!("{app}/{path} is ignored by .gitignore")
2278                        } else {
2279                            match std::fs::read_to_string(&file) {
2280                                Err(e) => format!("cannot read {app}/{path}: {e}"),
2281                                Ok(text) => match apply_hashline_edits(&text, &edits) {
2282                                    Err(e) => e,
2283                                    Ok((new_text, diff)) => match std::fs::write(&file, new_text) {
2284                                        Ok(()) => {
2285                                            format!(
2286                                                "edited {app}/{path} — {}{diff}",
2287                                                self.app_link(&uuid)
2288                                            )
2289                                        }
2290                                        Err(e) => format!("write failed: {e}"),
2291                                    },
2292                                },
2293                            }
2294                        }
2295                    }
2296                };
2297                (result, status)
2298            }
2299            "diff_app" => {
2300                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2301                let field = |k: &str| {
2302                    v.get(k)
2303                        .and_then(|x| x.as_str())
2304                        .unwrap_or_default()
2305                        .to_string()
2306                };
2307                let (app, path, content) = (field("app"), field("path"), field("content"));
2308                let status = format!("Diffing {app}/{path}…");
2309                let result = match self.resolve_app(&app) {
2310                    Err(e) => e,
2311                    Ok((_uuid, app_dir)) => {
2312                        if path.is_empty() || path.starts_with('/') || path.contains("..") {
2313                            format!("invalid path: {path:?}")
2314                        } else {
2315                            let file = app_dir.join(&path);
2316                            if app_path_ignored(&app_dir, &file) {
2317                                format!("{app}/{path} is ignored by .gitignore")
2318                            } else {
2319                                match std::fs::read_to_string(&file) {
2320                                    Ok(current) => unified_diff(&app, &path, &current, &content),
2321                                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2322                                        unified_diff(&app, &path, "", &content)
2323                                    }
2324                                    Err(e) => format!("cannot read {app}/{path}: {e}"),
2325                                }
2326                            }
2327                        }
2328                    }
2329                };
2330                (result, status)
2331            }
2332            "grep_app" => {
2333                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2334                let field = |k: &str| {
2335                    v.get(k)
2336                        .and_then(|x| x.as_str())
2337                        .unwrap_or_default()
2338                        .to_string()
2339                };
2340                let (app, pattern) = (field("app"), field("pattern"));
2341                let compact = v
2342                    .get("compact")
2343                    .and_then(serde_json::Value::as_bool)
2344                    .unwrap_or(true);
2345                let status = format!("Searching {app}…");
2346                let result = match self.resolve_app(&app) {
2347                    Err(e) => e,
2348                    Ok((_uuid, app_dir)) => {
2349                        if !app_dir.is_dir() {
2350                            format!("unknown app: {app}")
2351                        } else if pattern.is_empty() {
2352                            "pattern must not be empty".to_string()
2353                        } else {
2354                            let mut hits = Vec::new();
2355                            grep_dir(&app_dir, &app_dir, &pattern.to_lowercase(), &mut hits);
2356                            if hits.is_empty() {
2357                                format!("no matches for {pattern:?} in {app}")
2358                            } else {
2359                                let n = hits.len();
2360                                hits.truncate(50);
2361                                let mut by_file = std::collections::BTreeMap::<
2362                                    String,
2363                                    Vec<(usize, String)>,
2364                                >::new();
2365                                for (path, line) in hits {
2366                                    let text = if compact {
2367                                        String::new()
2368                                    } else {
2369                                        std::fs::read_to_string(app_dir.join(&path))
2370                                            .ok()
2371                                            .and_then(|contents| {
2372                                                contents
2373                                                    .lines()
2374                                                    .nth(line.saturating_sub(1))
2375                                                    .map(str::to_string)
2376                                            })
2377                                            .unwrap_or_default()
2378                                    };
2379                                    by_file.entry(path).or_default().push((line, text));
2380                                }
2381                                let mut result = by_file
2382                                    .into_iter()
2383                                    .map(|(path, lines)| {
2384                                        if compact {
2385                                            let numbers = lines
2386                                                .into_iter()
2387                                                .map(|(line, _)| line.to_string())
2388                                                .collect::<Vec<_>>()
2389                                                .join(",");
2390                                            format!("{path}:{numbers}")
2391                                        } else {
2392                                            lines
2393                                                .into_iter()
2394                                                .map(|(line, text)| {
2395                                                    format!("{path}:{line}: {text}")
2396                                                })
2397                                                .collect::<Vec<_>>()
2398                                                .join("\n")
2399                                        }
2400                                    })
2401                                    .collect::<Vec<_>>()
2402                                    .join("\n");
2403                                if n > 50 {
2404                                    let _ = write!(result, "\n… ({} more matches)", n - 50);
2405                                }
2406                                result
2407                            }
2408                        }
2409                    }
2410                };
2411                (result, status)
2412            }
2413            "read_app_file" => {
2414                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2415                let field = |k: &str| {
2416                    v.get(k)
2417                        .and_then(|x| x.as_str())
2418                        .unwrap_or_default()
2419                        .to_string()
2420                };
2421                let (app, path) = (field("app"), field("path"));
2422                let offset = usize::try_from(
2423                    v.get("offset")
2424                        .and_then(serde_json::Value::as_u64)
2425                        .unwrap_or(1)
2426                        .max(1),
2427                )
2428                .unwrap_or(1);
2429                let limit = v
2430                    .get("limit")
2431                    .and_then(serde_json::Value::as_u64)
2432                    .unwrap_or(200)
2433                    .clamp(1, 200) as usize;
2434                let status = format!("Reading {app}/{path}…");
2435                let result = match self.resolve_app(&app) {
2436                    Err(e) => e,
2437                    Ok((_uuid, app_dir)) => {
2438                        let file = app_dir.join(&path);
2439                        if path.is_empty() || path.starts_with('/') || path.contains("..") {
2440                            format!("invalid path: {path:?}")
2441                        } else if app_path_ignored(&app_dir, &file) {
2442                            format!("{app}/{path} is ignored by .gitignore")
2443                        } else {
2444                            match std::fs::read_to_string(&file) {
2445                                Err(e) => format!("cannot read {app}/{path}: {e}"),
2446                                Ok(text) => {
2447                                    let lines: Vec<&str> = text.lines().collect();
2448                                    let total = lines.len();
2449                                    let start = (offset - 1).min(total);
2450                                    let slice = &lines[start..(start + limit).min(total)];
2451                                    if slice.is_empty() {
2452                                        format!(
2453                                            "{app}/{path}: offset {offset} is past the end ({total} lines)"
2454                                        )
2455                                    } else {
2456                                        format!(
2457                                            "{app}/{path} (lines {}-{} of {total}):\n{}",
2458                                            start + 1,
2459                                            start + slice.len(),
2460                                            number_lines_with_hash(slice, start),
2461                                        )
2462                                    }
2463                                }
2464                            }
2465                        }
2466                    }
2467                };
2468                (result, status)
2469            }
2470            "generate_image" => {
2471                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2472                let prompt = v.get("prompt").and_then(|x| x.as_str()).unwrap_or("");
2473                let image_id = v
2474                    .get("image_id")
2475                    .and_then(|x| x.as_str())
2476                    .filter(|x| !x.is_empty());
2477                let size = v
2478                    .get("size")
2479                    .and_then(|x| x.as_str())
2480                    .unwrap_or("1024x1024");
2481                let status = if image_id.is_some() {
2482                    "Editing image…".to_string()
2483                } else {
2484                    "Generating image…".to_string()
2485                };
2486                let result = match &self.image_gen_backend {
2487                    None => "no image generation model configured — set one in /config".to_string(),
2488                    Some((provider, model)) => {
2489                        if prompt.is_empty() {
2490                            "prompt must not be empty".to_string()
2491                        } else {
2492                            let image_data = image_id.and_then(|id| {
2493                                // Try id as a full filename first, then as a stem + .png.
2494                                resolve_image(&self.space_files_dir, id).or_else(|| {
2495                                    let stem = std::path::Path::new(id)
2496                                        .file_stem()
2497                                        .and_then(|s| s.to_str())
2498                                        .unwrap_or(id);
2499                                    let files = self.space_files_dir.as_path();
2500                                    std::fs::read_dir(files).ok().and_then(|e| {
2501                                        e.flatten()
2502                                            .find(|e| {
2503                                                e.path().file_stem().is_some_and(|s| s == stem)
2504                                            })
2505                                            .and_then(|e| std::fs::read(e.path()).ok())
2506                                    })
2507                                })
2508                            });
2509                            if let Some(id) = image_id.filter(|_| image_data.is_none()) {
2510                                format!("image not found: {id}")
2511                            } else {
2512                                match provider
2513                                    .generate_image(model, prompt, size, image_data.as_deref())
2514                                    .await
2515                                {
2516                                    Err(e) => format!("image generation failed: {e}"),
2517                                    Ok((png_bytes, ext)) => {
2518                                        let id = uuid::Uuid::new_v4().to_string();
2519                                        let filename = format!("{id}.{ext}");
2520                                        let img_path = self.space_files_dir.join(&filename);
2521                                        if let Err(e) =
2522                                            std::fs::create_dir_all(&self.space_files_dir)
2523                                        {
2524                                            format!("cannot create images dir: {e}")
2525                                        } else if let Err(e) = std::fs::write(&img_path, &png_bytes)
2526                                        {
2527                                            format!("cannot write image: {e}")
2528                                        } else {
2529                                            let _ = std::fs::create_dir_all(&self.space_files_dir);
2530                                            let _ = std::fs::write(
2531                                                self.space_files_dir.join(&filename),
2532                                                &png_bytes,
2533                                            );
2534                                            let description =
2535                                                format!("generated image of {prompt}");
2536                                            serde_json::json!({
2537                                                "id": id,
2538                                                "path": img_path.to_string_lossy(),
2539                                                "description": description,
2540                                            })
2541                                            .to_string()
2542                                        }
2543                                    }
2544                                }
2545                            }
2546                        }
2547                    }
2548                };
2549                (result, status)
2550            }
2551            "generate_video" => {
2552                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2553                let prompt = v.get("prompt").and_then(|x| x.as_str()).unwrap_or("");
2554                let duration = u32::try_from(
2555                    v.get("duration")
2556                        .and_then(serde_json::Value::as_u64)
2557                        .unwrap_or(6),
2558                )
2559                .unwrap_or(6);
2560                let resolution = v
2561                    .get("resolution")
2562                    .and_then(|x| x.as_str())
2563                    .unwrap_or("720p");
2564                let aspect_ratio = v
2565                    .get("aspect_ratio")
2566                    .and_then(|x| x.as_str())
2567                    .unwrap_or("16:9");
2568                let generate_audio = v
2569                    .get("generate_audio")
2570                    .and_then(serde_json::Value::as_bool)
2571                    .unwrap_or(false);
2572                let first_frame_id = v
2573                    .get("first_frame_id")
2574                    .and_then(|x| x.as_str())
2575                    .filter(|x| !x.is_empty());
2576                let last_frame_id = v
2577                    .get("last_frame_id")
2578                    .and_then(|x| x.as_str())
2579                    .filter(|x| !x.is_empty());
2580                let ref_image_id = v
2581                    .get("ref_image_id")
2582                    .and_then(|x| x.as_str())
2583                    .filter(|x| !x.is_empty());
2584                let character_refs: Vec<String> = v
2585                    .get("character_refs")
2586                    .and_then(|x| x.as_array())
2587                    .map(|a| {
2588                        a.iter()
2589                            .filter_map(|x| x.as_str().map(String::from))
2590                            .collect()
2591                    })
2592                    .unwrap_or_default();
2593                let location_refs: Vec<String> = v
2594                    .get("location_refs")
2595                    .and_then(|x| x.as_array())
2596                    .map(|a| {
2597                        a.iter()
2598                            .filter_map(|x| x.as_str().map(String::from))
2599                            .collect()
2600                    })
2601                    .unwrap_or_default();
2602                let seed = v
2603                    .get("seed")
2604                    .and_then(serde_json::Value::as_i64)
2605                    .map(|x| i32::try_from(x).unwrap_or_default());
2606                let source_video_id = v
2607                    .get("source_video_id")
2608                    .and_then(|x| x.as_str())
2609                    .filter(|x| !x.is_empty());
2610                let status = "Generating video…".to_string();
2611                let result = match &self.video_gen_backend {
2612                    None => "no video generation model configured — set one in /config".to_string(),
2613                    Some((provider, model)) => {
2614                        let (duration, resolution, aspect_ratio) =
2615                            normalize_video_params(model, duration, resolution, aspect_ratio);
2616                        if prompt.is_empty() {
2617                            "prompt must not be empty".to_string()
2618                        } else {
2619                            let first_frame = first_frame_id
2620                                .and_then(|id| resolve_image(&self.space_files_dir, id));
2621                            let last_frame = last_frame_id
2622                                .and_then(|id| resolve_image(&self.space_files_dir, id));
2623                            let ref_img = ref_image_id
2624                                .and_then(|id| resolve_image(&self.space_files_dir, id));
2625                            let named = resolve_named_references(
2626                                &self.space_files_dir,
2627                                &character_refs,
2628                                &location_refs,
2629                            );
2630                            let mut all_refs = Vec::new();
2631                            if let Some(d) = ref_img {
2632                                all_refs.push(d);
2633                            }
2634                            all_refs.extend(named);
2635                            let provider_options = source_video_id.and_then(|sid| {
2636                                if !valid_relative_path(sid) {
2637                                    return None;
2638                                }
2639                                let path = self.space_files_dir.join(format!("{sid}.mp4"));
2640                                let data = std::fs::read(&path).ok()?;
2641                                let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
2642                                Some(serde_json::json!({
2643                                    "alibaba": {
2644                                        "parameters": {
2645                                            "video": format!("data:video/mp4;base64,{}", b64)
2646                                        }
2647                                    }
2648                                }))
2649                            });
2650                            match provider
2651                                .generate_video(crate::provider::openrouter::VideoRequest {
2652                                    model: model.clone(),
2653                                    prompt: prompt.to_string(),
2654                                    duration,
2655                                    resolution: resolution.clone(),
2656                                    aspect_ratio: aspect_ratio.clone(),
2657                                    generate_audio,
2658                                    first_frame,
2659                                    last_frame,
2660                                    input_references: all_refs,
2661                                    seed,
2662                                    provider_options,
2663                                })
2664                                .await
2665                            {
2666                                Err(e) => format!("video generation failed: {e}"),
2667                                Ok((mp4_bytes, cost)) => {
2668                                    let id = uuid::Uuid::new_v4().to_string();
2669                                    let video_filename = format!("{id}.mp4");
2670                                    let thumb_filename = format!("{id}_first.png");
2671                                    let last_thumb_filename = format!("{id}_last.png");
2672                                    let meta_filename = format!("{id}.json");
2673                                    let video_path = self.space_files_dir.join(&video_filename);
2674                                    if let Err(e) = std::fs::create_dir_all(&self.space_files_dir) {
2675                                        format!("cannot create files dir: {e}")
2676                                    } else if let Err(e) = std::fs::write(&video_path, &mp4_bytes) {
2677                                        format!("cannot write video: {e}")
2678                                    } else {
2679                                        let thumb_path = self.space_files_dir.join(&thumb_filename);
2680                                        let last_thumb_path =
2681                                            self.space_files_dir.join(&last_thumb_filename);
2682                                        let has_ffmpeg =
2683                                            extract_ffmpeg_frame(&video_path, &thumb_path, false);
2684                                        if has_ffmpeg {
2685                                            extract_ffmpeg_frame(
2686                                                &video_path,
2687                                                &last_thumb_path,
2688                                                true,
2689                                            );
2690                                        } else if let Some(fid) = first_frame_id
2691                                            && let Some(data) =
2692                                                resolve_image(&self.space_files_dir, fid)
2693                                        {
2694                                            let _ = std::fs::write(&thumb_path, data);
2695                                        }
2696                                        let now = chrono::Utc::now().to_rfc3339();
2697                                        let meta = serde_json::json!({
2698                                            "type": "generated_video",
2699                                            "video_id": id,
2700                                            "prompt": prompt,
2701                                            "model": model,
2702                                            "duration_sec": duration,
2703                                            "resolution": resolution,
2704                                            "aspect_ratio": aspect_ratio,
2705                                            "has_audio": generate_audio,
2706                                            "character_refs": character_refs,
2707                                            "location_refs": location_refs,
2708                                            "seed": seed,
2709                                            "cost_usd": cost,
2710                                            "generated_at": now,
2711                                        });
2712                                        let _ = std::fs::write(
2713                                            self.space_files_dir.join(&meta_filename),
2714                                            serde_json::to_string_pretty(&meta).unwrap_or_default(),
2715                                        );
2716                                        let desc = format!("generated video of {prompt}");
2717                                        serde_json::json!({
2718                                            "id": id,
2719                                            "video_path": video_path.to_string_lossy(),
2720                                            "thumbnail_path": if thumb_path.exists() { thumb_path.to_string_lossy().to_string() } else { String::new() },
2721                                            "metadata_path": self.space_files_dir.join(&meta_filename).to_string_lossy(),
2722                                            "description": desc,
2723                                            "last_thumb": if last_thumb_path.exists() { last_thumb_path.to_string_lossy().to_string() } else { String::new() },
2724                                            "model": model,
2725                                            "duration_sec": duration,
2726                                            "cost_usd": cost,
2727                                        }).to_string()
2728                                    }
2729                                }
2730                            }
2731                        }
2732                    }
2733                };
2734                (result, status)
2735            }
2736            "edit_video" => {
2737                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2738                let video_id = v
2739                    .get("video_id")
2740                    .and_then(|x| x.as_str())
2741                    .unwrap_or_default()
2742                    .to_string();
2743                let lighting = v
2744                    .get("lighting")
2745                    .and_then(|x| x.as_str())
2746                    .filter(|x| !x.is_empty());
2747                let camera_move = v
2748                    .get("camera_move")
2749                    .and_then(|x| x.as_str())
2750                    .filter(|x| !x.is_empty());
2751                let intensity = v
2752                    .get("intensity")
2753                    .and_then(serde_json::Value::as_f64)
2754                    .unwrap_or(0.5)
2755                    .clamp(0.0, 1.0);
2756                let speed = v
2757                    .get("speed")
2758                    .and_then(serde_json::Value::as_f64)
2759                    .filter(|x| *x > 0.0);
2760                let trim_start = v
2761                    .get("trim_start")
2762                    .and_then(serde_json::Value::as_f64)
2763                    .filter(|x| *x >= 0.0);
2764                let trim_end = v
2765                    .get("trim_end")
2766                    .and_then(serde_json::Value::as_f64)
2767                    .filter(|x| *x >= 0.0);
2768                let remove_audio = v
2769                    .get("remove_audio")
2770                    .and_then(serde_json::Value::as_bool)
2771                    .unwrap_or(false);
2772                let status = "Editing video…".to_string();
2773                let result = if video_id.is_empty() {
2774                    "video_id is required".to_string()
2775                } else if !valid_relative_path(&video_id) {
2776                    "invalid video_id".to_string()
2777                } else if !ffmpeg_available() {
2778                    "ffmpeg not found — install ffmpeg to edit videos".to_string()
2779                } else {
2780                    let src_path = self.space_files_dir.join(format!("{video_id}.mp4"));
2781                    if src_path.exists() {
2782                        let id = uuid::Uuid::new_v4().to_string();
2783                        let output_path = self.space_files_dir.join(format!("{id}.mp4"));
2784                        let thumb_path = self.space_files_dir.join(format!("{id}_first.png"));
2785                        let meta_path = self.space_files_dir.join(format!("{id}.json"));
2786
2787                        let mut cmd = std::process::Command::new("ffmpeg");
2788                        cmd.arg("-y");
2789                        if let Some(s) = trim_start {
2790                            cmd.arg("-ss").arg(format!("{s}"));
2791                        }
2792                        if let Some(e) = trim_end {
2793                            cmd.arg("-to").arg(format!("{e}"));
2794                        }
2795                        cmd.arg("-i").arg(&src_path);
2796
2797                        let mut filter_parts: Vec<String> = Vec::new();
2798
2799                        // Camera move (crop animation)
2800                        if let Some(mv) = camera_move {
2801                            let m = intensity * 0.2;
2802                            let cf = build_camera_filter(mv, m);
2803                            filter_parts.push(cf);
2804                        }
2805
2806                        // Lighting preset
2807                        if let Some(lt) = lighting {
2808                            let lf = build_lighting_filter(lt, intensity);
2809                            filter_parts.push(lf);
2810                        }
2811
2812                        // Speed change
2813                        if let Some(sp) = speed {
2814                            filter_parts.push(format!("setpts={}*PTS", 1.0 / sp));
2815                            let atempo = format!("atempo={}", (1.0 / sp).clamp(0.5, 2.0));
2816                            cmd.arg("-af").arg(&atempo);
2817                        }
2818
2819                        if !filter_parts.is_empty() {
2820                            cmd.arg("-vf").arg(filter_parts.join(","));
2821                        }
2822
2823                        if remove_audio {
2824                            cmd.arg("-an");
2825                        }
2826
2827                        cmd.arg(&output_path)
2828                            .stdout(std::process::Stdio::null())
2829                            .stderr(std::process::Stdio::null());
2830
2831                        match cmd.status() {
2832                            Err(e) => format!("ffmpeg failed to start: {e}"),
2833                            Ok(s) if !s.success() => "ffmpeg returned non-zero exit".to_string(),
2834                            Ok(_) => {
2835                                extract_ffmpeg_frame(&output_path, &thumb_path, false);
2836                                let now = chrono::Utc::now().to_rfc3339();
2837                                let meta = serde_json::json!({
2838                                    "type": "edited_video",
2839                                    "video_id": id,
2840                                    "source_video_id": video_id,
2841                                    "lighting": lighting,
2842                                    "camera_move": camera_move,
2843                                    "intensity": intensity,
2844                                    "speed": speed,
2845                                    "trim_start": trim_start,
2846                                    "trim_end": trim_end,
2847                                    "remove_audio": remove_audio,
2848                                    "generated_at": now,
2849                                });
2850                                let _ = std::fs::write(
2851                                    &meta_path,
2852                                    serde_json::to_string_pretty(&meta).unwrap_or_default(),
2853                                );
2854                                serde_json::json!({
2855                                    "id": id,
2856                                    "video_path": output_path.to_string_lossy(),
2857                                    "thumbnail_path": if thumb_path.exists() { thumb_path.to_string_lossy().to_string() } else { String::new() },
2858                                    "metadata_path": meta_path.to_string_lossy(),
2859                                    "description": format!("edited video from {video_id}"),
2860                                }).to_string()
2861                            }
2862                        }
2863                    } else {
2864                        format!("video '{video_id}' not found")
2865                    }
2866                };
2867                (result, status)
2868            }
2869            "extract_frame" => {
2870                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2871                let video_id = v
2872                    .get("video_id")
2873                    .and_then(|x| x.as_str())
2874                    .unwrap_or_default()
2875                    .to_string();
2876                let time_sec = v
2877                    .get("time_sec")
2878                    .and_then(serde_json::Value::as_f64)
2879                    .unwrap_or(0.0);
2880                let fmt = v.get("format").and_then(|x| x.as_str()).unwrap_or("png");
2881                let status = "Extracting frame…".to_string();
2882                let result = if video_id.is_empty() {
2883                    "video_id is required".to_string()
2884                } else if !valid_relative_path(&video_id) {
2885                    "invalid video_id".to_string()
2886                } else {
2887                    let src_path = self.space_files_dir.join(format!("{video_id}.mp4"));
2888                    if !src_path.exists() {
2889                        format!("video '{video_id}' not found")
2890                    } else if !ffmpeg_available() {
2891                        "ffmpeg not found — install ffmpeg to extract frames".to_string()
2892                    } else {
2893                        let id = uuid::Uuid::new_v4().to_string();
2894                        let ext = if fmt == "jpg" { "jpg" } else { "png" };
2895                        let output = self.space_files_dir.join(format!("{id}.{ext}"));
2896                        let status_code = std::process::Command::new("ffmpeg")
2897                            .arg("-y")
2898                            .arg("-ss")
2899                            .arg(format!("{time_sec}"))
2900                            .arg("-i")
2901                            .arg(&src_path)
2902                            .arg("-vframes")
2903                            .arg("1")
2904                            .arg("-f")
2905                            .arg("image2")
2906                            .arg(&output)
2907                            .stdout(std::process::Stdio::null())
2908                            .stderr(std::process::Stdio::null())
2909                            .status();
2910                        match status_code {
2911                            Err(_) => "ffmpeg execution failed".to_string(),
2912                            Ok(s) if !s.success() => "ffmpeg failed to extract frame".to_string(),
2913                            Ok(_) => {
2914                                serde_json::json!({
2915                                    "id": id,
2916                                    "path": output.to_string_lossy(),
2917                                    "description": format!("frame at {time_sec}s from video {video_id}"),
2918                                }).to_string()
2919                            }
2920                        }
2921                    }
2922                };
2923                (result, status)
2924            }
2925            "stitch_videos" => {
2926                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
2927                let video_ids: Vec<String> = v
2928                    .get("video_ids")
2929                    .and_then(|x| x.as_array())
2930                    .map(|a| {
2931                        a.iter()
2932                            .filter_map(|x| x.as_str().map(String::from))
2933                            .collect()
2934                    })
2935                    .unwrap_or_default();
2936                let status = "Stitching videos…".to_string();
2937                let result = if video_ids.is_empty() {
2938                    "video_ids must not be empty".to_string()
2939                } else if !ffmpeg_available() {
2940                    "ffmpeg not found — install ffmpeg to stitch videos".to_string()
2941                } else {
2942                    let mut video_files = Vec::new();
2943                    let mut total_cost = 0.0_f64;
2944                    for vid_id in &video_ids {
2945                        if !valid_relative_path(vid_id) {
2946                            break;
2947                        }
2948                        let mp4 = self.space_files_dir.join(format!("{vid_id}.mp4"));
2949                        if !mp4.exists() {
2950                            break;
2951                        }
2952                        let meta_path = self.space_files_dir.join(format!("{vid_id}.json"));
2953                        if let Ok(json_str) = std::fs::read_to_string(&meta_path)
2954                            && let Ok(meta) = serde_json::from_str::<serde_json::Value>(&json_str)
2955                        {
2956                            total_cost += meta
2957                                .get("cost_usd")
2958                                .and_then(serde_json::Value::as_f64)
2959                                .unwrap_or(0.0);
2960                        }
2961                        video_files.push(mp4);
2962                    }
2963                    if video_files.len() == video_ids.len() {
2964                        let id = uuid::Uuid::new_v4().to_string();
2965                        let concat_name = format!("_stitch_concat_{id}.txt");
2966                        let concat_path = self.space_files_dir.join(&concat_name);
2967                        let output_name = format!("_stitch_{id}.mp4");
2968                        let output_path = self.space_files_dir.join(&output_name);
2969                        let thumb_name = format!("{id}_first.png");
2970                        let thumb_path = self.space_files_dir.join(&thumb_name);
2971                        let concat_content: String =
2972                            video_files.iter().fold(String::new(), |mut c, p| {
2973                                let _ = writeln!(c, "file '{}'", p.display());
2974                                c
2975                            });
2976                        if let Err(e) = std::fs::write(&concat_path, &concat_content) {
2977                            format!("cannot write concat file: {e}")
2978                        } else {
2979                            let stitched = std::process::Command::new("ffmpeg")
2980                                .arg("-y")
2981                                .arg("-f")
2982                                .arg("concat")
2983                                .arg("-safe")
2984                                .arg("0")
2985                                .arg("-i")
2986                                .arg(&concat_path)
2987                                .arg("-c")
2988                                .arg("copy")
2989                                .arg(&output_path)
2990                                .stdout(std::process::Stdio::null())
2991                                .stderr(std::process::Stdio::null())
2992                                .status();
2993                            let _ = std::fs::remove_file(&concat_path);
2994                            match stitched {
2995                                Err(_) => "ffmpeg execution failed".to_string(),
2996                                Ok(s) if !s.success() => "ffmpeg concat failed".to_string(),
2997                                Ok(_) => {
2998                                    if let Some(first) = video_files.first() {
2999                                        extract_ffmpeg_frame(first, &thumb_path, false);
3000                                    }
3001                                    let now = chrono::Utc::now().to_rfc3339();
3002                                    let seq_meta = serde_json::json!({
3003                                        "type": "video_sequence",
3004                                        "video_id": id,
3005                                        "shot_ids": video_ids,
3006                                        "total_cost_usd": total_cost,
3007                                        "generated_at": now,
3008                                    });
3009                                    let _ = std::fs::write(
3010                                        self.space_files_dir.join(format!("{id}.json")),
3011                                        serde_json::to_string_pretty(&seq_meta).unwrap_or_default(),
3012                                    );
3013                                    serde_json::json!({
3014                                        "id": id,
3015                                        "video_path": output_path.to_string_lossy(),
3016                                        "thumbnail_path": if thumb_path.exists() { thumb_path.to_string_lossy().to_string() } else { String::new() },
3017                                        "metadata_path": self.space_files_dir.join(format!("{id}.json")).to_string_lossy(),
3018                                        "description": format!("stitched sequence of {} clips", video_files.len()),
3019                                        "cost_usd": total_cost,
3020                                    }).to_string()
3021                                }
3022                            }
3023                        }
3024                    } else {
3025                        format!("some video files not found for IDs: {video_ids:?}")
3026                    }
3027                };
3028                (result, status)
3029            }
3030            "save_reference" => {
3031                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
3032                let name = v
3033                    .get("name")
3034                    .and_then(|x| x.as_str())
3035                    .unwrap_or_default()
3036                    .to_string();
3037                let ref_type = v
3038                    .get("type")
3039                    .and_then(|x| x.as_str())
3040                    .unwrap_or("character")
3041                    .to_string();
3042                let image_id = v
3043                    .get("image_id")
3044                    .and_then(|x| x.as_str())
3045                    .unwrap_or_default()
3046                    .to_string();
3047                let description = v
3048                    .get("description")
3049                    .and_then(|x| x.as_str())
3050                    .unwrap_or_default()
3051                    .to_string();
3052                let status = format!("Saving reference '{name}'…");
3053                let result = if name.is_empty() || image_id.is_empty() {
3054                    "name and image_id are required".to_string()
3055                } else {
3056                    let mut refs = read_video_refs(&self.space_files_dir);
3057                    if let Some(obj) = refs.as_object_mut() {
3058                        obj.insert(
3059                            name.clone(),
3060                            serde_json::json!({
3061                                "name": name,
3062                                "type": ref_type,
3063                                "description": description,
3064                                "image_id": image_id,
3065                                "created_at": chrono::Utc::now().to_rfc3339(),
3066                            }),
3067                        );
3068                    }
3069                    match write_video_refs(&self.space_files_dir, &refs) {
3070                        Err(e) => format!("failed to save reference: {e}"),
3071                        Ok(()) => format!(
3072                            "saved reference '{name}' ({ref_type}) — use in generate_video with character_refs/location_refs"
3073                        ),
3074                    }
3075                };
3076                (result, status)
3077            }
3078            "list_references" => {
3079                let status = "Listing references…".to_string();
3080                let refs = read_video_refs(&self.space_files_dir);
3081                let result = if refs.as_object().is_none_or(serde_json::Map::is_empty) {
3082                    "no references saved yet — use video_references with action=save to create one"
3083                        .to_string()
3084                } else {
3085                    let pretty: Vec<serde_json::Value> = refs
3086                        .as_object()
3087                        .map(|o| o.values().cloned().collect())
3088                        .unwrap_or_default();
3089                    serde_json::to_string_pretty(&pretty).unwrap_or_default()
3090                };
3091                (result, status)
3092            }
3093            "delete_reference" => {
3094                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
3095                let name = v
3096                    .get("name")
3097                    .and_then(|x| x.as_str())
3098                    .unwrap_or_default()
3099                    .to_string();
3100                let status = format!("Deleting reference '{name}'…");
3101                let result = if name.is_empty() {
3102                    "name is required".to_string()
3103                } else {
3104                    let mut refs = read_video_refs(&self.space_files_dir);
3105                    if refs.get(&name).is_none() {
3106                        format!("reference '{name}' not found")
3107                    } else {
3108                        refs.as_object_mut().map(|o| o.remove(&name));
3109                        match write_video_refs(&self.space_files_dir, &refs) {
3110                            Err(e) => format!("failed to delete reference: {e}"),
3111                            Ok(()) => format!("deleted reference '{name}'"),
3112                        }
3113                    }
3114                };
3115                (result, status)
3116            }
3117            "list_scripts" => {
3118                let status = "Listing scripts…".to_string();
3119                let result = match std::fs::read_dir(&self.space_scripts_dir) {
3120                    Err(_) => "[]".to_string(),
3121                    Ok(entries) => {
3122                        let scripts: Vec<serde_json::Value> = entries
3123                            .flatten()
3124                            .filter(|e| e.path().is_file())
3125                            .filter_map(|e| {
3126                                let meta = e.metadata().ok()?;
3127                                let ext = e
3128                                    .path()
3129                                    .extension()
3130                                    .and_then(|x| x.to_str())
3131                                    .unwrap_or("")
3132                                    .to_string();
3133                                Some(serde_json::json!({
3134                                    "name": e.file_name().to_string_lossy(),
3135                                    "size": meta.len(),
3136                                    "ext": ext,
3137                                }))
3138                            })
3139                            .collect();
3140                        serde_json::to_string(&scripts).unwrap_or_else(|_| "[]".to_string())
3141                    }
3142                };
3143                (result, status)
3144            }
3145            "write_script" => {
3146                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
3147                let path = v
3148                    .get("path")
3149                    .and_then(|x| x.as_str())
3150                    .unwrap_or_default()
3151                    .to_string();
3152                let content = v
3153                    .get("content")
3154                    .and_then(|x| x.as_str())
3155                    .unwrap_or_default()
3156                    .to_string();
3157                let status = format!("Writing {path}…");
3158                let result = {
3159                    let file = self.space_scripts_dir.join(&path);
3160                    if valid_relative_path(&path) {
3161                        let write = file
3162                            .parent()
3163                            .map_or(Ok(()), std::fs::create_dir_all)
3164                            .and_then(|()| std::fs::write(&file, &content));
3165                        match write {
3166                            Ok(()) => format!("wrote {path} ({} bytes)", content.len()),
3167                            Err(e) => format!("write failed: {e}"),
3168                        }
3169                    } else {
3170                        format!("invalid path: {path:?}")
3171                    }
3172                };
3173                (result, status)
3174            }
3175            "read_script" => {
3176                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
3177                let path = v
3178                    .get("path")
3179                    .and_then(|x| x.as_str())
3180                    .unwrap_or_default()
3181                    .to_string();
3182                let offset = usize::try_from(
3183                    v.get("offset")
3184                        .and_then(serde_json::Value::as_u64)
3185                        .unwrap_or(1)
3186                        .max(1),
3187                )
3188                .unwrap_or(1);
3189                let limit = v
3190                    .get("limit")
3191                    .and_then(serde_json::Value::as_u64)
3192                    .unwrap_or(200)
3193                    .clamp(1, 200) as usize;
3194                let status = format!("Reading {path}…");
3195                let result = {
3196                    let file = self.space_scripts_dir.join(&path);
3197                    if valid_relative_path(&path) {
3198                        match std::fs::read_to_string(&file) {
3199                            Err(e) => format!("cannot read {path}: {e}"),
3200                            Ok(text) => {
3201                                let lines: Vec<&str> = text.lines().collect();
3202                                let total = lines.len();
3203                                let start = (offset - 1).min(total);
3204                                let slice = &lines[start..(start + limit).min(total)];
3205                                if slice.is_empty() {
3206                                    format!(
3207                                        "{path}: offset {offset} is past the end ({total} lines)"
3208                                    )
3209                                } else {
3210                                    format!(
3211                                        "{path} (lines {}-{} of {total}):\n{}",
3212                                        start + 1,
3213                                        start + slice.len(),
3214                                        number_lines_with_hash(slice, start),
3215                                    )
3216                                }
3217                            }
3218                        }
3219                    } else {
3220                        format!("invalid path: {path:?}")
3221                    }
3222                };
3223                (result, status)
3224            }
3225            "edit_script" => {
3226                let v = serde_json::from_str::<serde_json::Value>(args).unwrap_or_default();
3227                let path = v
3228                    .get("path")
3229                    .and_then(|x| x.as_str())
3230                    .unwrap_or_default()
3231                    .to_string();
3232                let edits: Vec<(String, Option<usize>, Option<String>)> = v
3233                    .get("edits")
3234                    .and_then(|e| e.as_array())
3235                    .map(|arr| {
3236                        arr.iter()
3237                            .filter_map(|e| {
3238                                let hash = e.get("hash")?.as_str()?.to_string();
3239                                let line = e
3240                                    .get("line")
3241                                    .and_then(serde_json::Value::as_u64)
3242                                    .and_then(|n| usize::try_from(n).ok());
3243                                let new = e.get("new").and_then(|n| n.as_str()).map(str::to_string);
3244                                Some((hash, line, new))
3245                            })
3246                            .collect()
3247                    })
3248                    .unwrap_or_default();
3249                let status = format!("Editing {path}…");
3250                let result = {
3251                    let file = self.space_scripts_dir.join(&path);
3252                    if valid_relative_path(&path) {
3253                        match std::fs::read_to_string(&file) {
3254                            Err(e) => format!("cannot read {path}: {e}"),
3255                            Ok(text) => match apply_hashline_edits(&text, &edits) {
3256                                Err(e) => e,
3257                                Ok((new_text, diff)) => match std::fs::write(&file, new_text) {
3258                                    Ok(()) => format!("edited {path} — {diff}"),
3259                                    Err(e) => format!("write failed: {e}"),
3260                                },
3261                            },
3262                        }
3263                    } else {
3264                        format!("invalid path: {path:?}")
3265                    }
3266                };
3267                (result, status)
3268            }
3269            other => (
3270                format!("unknown tool: {other}"),
3271                "Running tool…".to_string(),
3272            ),
3273        };
3274        let result = if name == "batch" {
3275            // The batch arm already bounded each sub-result; apply the larger
3276            // combined cap here so several packed results survive intact.
3277            cap_result(result, MAX_BATCH_RESULT_CHARS)
3278        } else {
3279            cap_tool_result(result)
3280        };
3281        (result, status)
3282    }
3283}
3284
3285/// Tools safe to run concurrently in one round-trip: they read state or hit
3286/// the network but never mutate files/db in ways that could race. Used to
3287/// parallelize both model-issued parallel tool calls and `batch` sub-calls.
3288/// Consolidated names (`search`, `files`, `skills`, `scripts`, `app`, `media`,
3289/// `research_lookup`) count as read-only only for their read-only actions —
3290/// the `action` argument decides. Legacy names (`skill`, `web_search`, …)
3291/// classify as before; mutating consolidations (`batch`) are never read-only.
3292pub fn is_read_only_tool(name: &str, args: &str) -> bool {
3293    let action_is = |actions: &[&str]| {
3294        serde_json::from_str::<serde_json::Value>(args)
3295            .ok()
3296            .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string))
3297            .is_some_and(|a| actions.contains(&a.as_str()))
3298    };
3299    match name {
3300        "skills" => action_is(&["load"]),
3301        "scripts" => action_is(&["list", "read"]),
3302        "app" => action_is(&["read", "search", "diff", "list"]),
3303        "media" => action_is(&["list_references"]),
3304        _ => matches!(
3305            name,
3306            "skill"
3307                | "search"
3308                | "web_search"
3309                | "academic_search"
3310                | "discussion_search"
3311                | "fetch_url"
3312                | "research_lookup"
3313                | "search_sources"
3314                | "list_citations"
3315                | "files"
3316                | "search_files"
3317                | "read_file"
3318                | "read_pdf_page"
3319                | "app_inspect"
3320                | "read_app_file"
3321                | "grep_app"
3322                | "diff_app"
3323                | "list_images"
3324                | "read_script"
3325                | "list_scripts"
3326                | "list_references"
3327        ),
3328    }
3329}
3330
3331/// Compact label for one `batch` sub-call, shown above its result so the
3332/// model can tell which output belongs to which operation.
3333fn batch_call_label(name: &str, v: &serde_json::Value) -> String {
3334    let s = |k: &str| {
3335        v.get(k)
3336            .and_then(|x| x.as_str())
3337            .unwrap_or_default()
3338            .to_string()
3339    };
3340    let quoted = |k: &str| format!("{:?}", s(k));
3341    match name {
3342        "skills" => format!("skills/{} {}", s("action"), s("name")),
3343        "scripts" => {
3344            let target = if s("action") == "install" {
3345                "packages".to_string()
3346            } else {
3347                s("path")
3348            };
3349            format!("scripts/{} {}", s("action"), target)
3350        }
3351        "app" => {
3352            let path = s("path");
3353            if path.is_empty() {
3354                format!("app/{} {}", s("action"), s("app"))
3355            } else {
3356                format!("app/{} {}/{}", s("action"), s("app"), path)
3357            }
3358        }
3359        "media" => {
3360            let target = [s("video_id"), s("name"), s("image_id")]
3361                .into_iter()
3362                .find(|t| !t.is_empty())
3363                .unwrap_or_else(|| s("prompt").chars().take(40).collect());
3364            format!("media/{} {}", s("action"), target)
3365        }
3366        "search" => format!("search {} {}", s("mode"), quoted("query")),
3367        "fetch_url" => format!("fetch_url {}", s("url")),
3368        "files" => {
3369            let target = if s("name").is_empty() {
3370                quoted("query")
3371            } else {
3372                s("name")
3373            };
3374            format!("files/{} {target}", s("action"))
3375        }
3376        "app_inspect" => format!("app_inspect/{} {}/{}", s("action"), s("app"), s("path")),
3377        "app_modify" => format!("app_modify/{} {}/{}", s("action"), s("app"), s("path")),
3378        "app_assets" => format!("app_assets/{} {}", s("action"), s("app")),
3379        "script_files" => format!("script_files/{} {}", s("action"), s("path")),
3380        "research_lookup" => format!("research_lookup/{} {}", s("scope"), quoted("query")),
3381        "web_search" => format!("web_search {:?}", s("query")),
3382        "academic_search" => format!("academic_search {:?}", s("query")),
3383        "discussion_search" => format!("discussion_search {:?}", s("query")),
3384        "search_files" => format!("search_files {:?}", s("query")),
3385        "search_sources" => format!("search_sources {:?}", s("query")),
3386        "list_citations" => "list_citations".to_string(),
3387        "read_file" => format!("read_file {}", s("name")),
3388        "read_pdf_page" => format!("read_pdf_page {} page {}", s("name"), s("page")),
3389        "read_app_file" => format!("read_app_file {}/{}", s("app"), s("path")),
3390        "grep_app" => format!("grep_app {} {:?}", s("app"), s("pattern")),
3391        "read_script" => format!("read_script {}", s("path")),
3392        "skill" => format!("skill {}", s("name")),
3393        "skill_admin" => format!("skill_admin {}", s("action")),
3394        "run_python" => format!("run_python {}", s("name")),
3395        "run_script" => format!("run_script {}", s("path")),
3396        "install_packages" => {
3397            let target = [s("skill"), s("app")]
3398                .into_iter()
3399                .find(|t| !t.is_empty())
3400                .unwrap_or_default();
3401            format!("install_packages {target}")
3402        }
3403        "generate_image" | "generate_video" => {
3404            let prompt: String = s("prompt").chars().take(60).collect();
3405            format!("{name} {prompt:?}")
3406        }
3407        _ => name.to_string(),
3408    }
3409}
3410
3411/// Marker prefix of `tool_result_unchanged_note` — a tool result omitted
3412/// because it is byte-identical to an earlier call with the same tool and
3413/// arguments. Both the live tool loop and `build_history` emit this exact
3414/// text for duplicates, which lets the loop recognize replayed notes when
3415/// seeding its dedup map. A real tool result beginning with this string
3416/// would at worst miss one dedup (and cause a single prompt-cache break),
3417/// never corrupt model-visible content.
3418pub const TOOL_RESULT_OMITTED_PREFIX: &str = "[result omitted: ";
3419
3420/// One-line replacement for a tool result that duplicates an earlier call
3421/// with the same tool and arguments. The first full copy stays in the
3422/// conversation, so nothing is lost; the note keeps the duplicate from
3423/// re-entering the context on every subsequent request.
3424pub fn tool_result_unchanged_note(name: &str, args: &str) -> String {
3425    let label = serde_json::from_str::<serde_json::Value>(args)
3426        .ok()
3427        .map_or_else(|| name.to_string(), |v| batch_call_label(name, &v));
3428    format!(
3429        "{TOOL_RESULT_OMITTED_PREFIX}{label} returned exactly the same result as an earlier \
3430         call — content unchanged; the earlier result is above]"
3431    )
3432}
3433
3434/// Bound every tool result before it is sent back to the model and persisted.
3435/// This is especially important for fetched web pages, which can otherwise
3436/// consume the conversation context one tool call at a time.
3437fn cap_tool_result(result: String) -> String {
3438    cap_result(result, MAX_TOOL_RESULT_CHARS)
3439}
3440
3441fn cap_result(result: String, max_chars: usize) -> String {
3442    const SUFFIX: &str = "\n... (tool result truncated)";
3443    if result.chars().count() <= max_chars {
3444        return result;
3445    }
3446    let keep = max_chars.saturating_sub(SUFFIX.chars().count());
3447    let mut capped: String = result.chars().take(keep).collect();
3448    capped.push_str(SUFFIX);
3449    capped
3450}
3451
3452/// Quick check: does a string look like a UUID (36 chars, 4 dashes)?
3453fn looks_like_uuid(s: &str) -> bool {
3454    s.len() == 36 && s.chars().filter(|c| *c == '-').count() == 4
3455}
3456
3457fn valid_relative_path(path: &str) -> bool {
3458    !path.is_empty()
3459        && !path.contains('\\')
3460        && std::path::Path::new(path)
3461            .components()
3462            .all(|component| matches!(component, std::path::Component::Normal(_)))
3463}
3464
3465/// `cat -n`-style numbering for ranged reads, matching what agent harnesses
3466/// feed models so line references and edits anchor reliably.
3467/// Search imported files: embed the query and rank chunks by cosine when an
3468/// embedder is configured; otherwise (or when embedding fails / no vectors
3469/// are stored yet) fall back to FTS keywords, tagged so the model knows the
3470/// weaker path answered.
3471async fn search_files_impl(ctx: &FilesCtx, query: &str) -> String {
3472    let conn = match crate::db::open_attached(&ctx.db_path) {
3473        Ok(c) => c,
3474        Err(e) => return format!("file search failed: {e}"),
3475    };
3476    let mut fell_back = false;
3477    if let Some((provider, model)) = &ctx.embedder {
3478        match provider.embed(model, vec![query.to_string()]).await {
3479            Ok(mut vecs) if !vecs.is_empty() => {
3480                if let Some(out) = semantic_snippets(&conn, &ctx.space_id, &vecs.remove(0)) {
3481                    return out;
3482                }
3483                fell_back = true; // nothing embedded yet — keywords still help
3484            }
3485            _ => fell_back = true, // endpoint down — degrade, don't die
3486        }
3487    }
3488    match crate::db::search_chunks(&conn, &ctx.space_id, query, 8) {
3489        Ok(hits) if hits.is_empty() => "no matches".to_string(),
3490        Ok(hits) => {
3491            let body = hits
3492                .iter()
3493                .map(|(name, loc, snip)| format!("{name} ({loc}): {snip}"))
3494                .collect::<Vec<_>>()
3495                .join("\n");
3496            if fell_back {
3497                format!("(keyword fallback)\n{body}")
3498            } else {
3499                body
3500            }
3501        }
3502        Err(e) => format!("file search failed: {e}"),
3503    }
3504}
3505
3506/// Top cosine-ranked chunks formatted as `name (location): text` (truncated),
3507/// or None when the space has no usable vectors.
3508fn semantic_snippets(conn: &rusqlite::Connection, space_id: &str, query: &[f32]) -> Option<String> {
3509    let hits = crate::db::semantic_chunks(conn, space_id, query, 8).ok()?;
3510    if hits.is_empty() {
3511        return None;
3512    }
3513    Some(
3514        hits.iter()
3515            .map(|(name, loc, text, _)| {
3516                let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
3517                let cut: String = flat.chars().take(300).collect();
3518                let ellipsis = if cut.len() < flat.len() { "…" } else { "" };
3519                format!("{name} ({loc}): {cut}{ellipsis}")
3520            })
3521            .collect::<Vec<_>>()
3522            .join("\n"),
3523    )
3524}
3525
3526fn number_lines(slice: &[&str], start: usize) -> String {
3527    slice
3528        .iter()
3529        .enumerate()
3530        .map(|(i, l)| format!("{:>5}\t{l}", start + i + 1))
3531        .collect::<Vec<_>>()
3532        .join("\n")
3533}
3534
3535/// sha256(content) truncated to 8 hex chars. Unlike a line-number hash,
3536/// this remains valid when an earlier edit inserts or removes lines. Repeated
3537/// identical lines are rejected as ambiguous unless the optional line hint is
3538/// supplied with the edit.
3539fn line_hash(content: &str) -> String {
3540    let mut hasher = Sha256::new();
3541    hasher.update(content);
3542    hasher
3543        .finalize()
3544        .iter()
3545        .take(4)
3546        .fold(String::new(), |mut h, b| {
3547            let _ = write!(h, "{b:02x}");
3548            h
3549        })
3550}
3551
3552/// Hash format used by older reads. Keep accepting it for a running session
3553/// after the stable content-hash format is deployed.
3554fn legacy_line_hash(line_no: usize, content: &str) -> String {
3555    let mut hasher = Sha256::new();
3556    hasher.update(format!("{line_no}:{content}"));
3557    hasher
3558        .finalize()
3559        .iter()
3560        .take(4)
3561        .fold(String::new(), |mut h, b| {
3562            let _ = write!(h, "{b:02x}");
3563            h
3564        })
3565}
3566
3567/// `read_app_file`'s line prefix: `N:HASH<tab>content`, one hash per line so
3568/// `edit_file` can target it without substring matching.
3569fn number_lines_with_hash(slice: &[&str], start: usize) -> String {
3570    slice
3571        .iter()
3572        .enumerate()
3573        .map(|(i, l)| {
3574            let n = start + i + 1;
3575            format!("{:>5}:{}\t{l}", n, line_hash(l))
3576        })
3577        .collect::<Vec<_>>()
3578        .join("\n")
3579}
3580
3581/// Apply hashline edits to `text`: each `(hash, line_hint, new)` targets the
3582/// matching line content (see `line_hash`). `new: None` deletes that line;
3583/// `Some(t)` replaces it with `t`'s lines (0, 1, or many — this is also how
3584/// you insert: include the original line's content in `t`). The optional
3585/// line hint disambiguates repeated identical lines. Hashes are resolved
3586/// against the current file and never silently land on a different line.
3587/// Returns the new file content plus a git-diff-style summary of what changed,
3588/// in the order edits were given.
3589fn apply_hashline_edits(
3590    text: &str,
3591    edits: &[(String, Option<usize>, Option<String>)],
3592) -> Result<(String, String), String> {
3593    if edits.is_empty() {
3594        return Err("edits must not be empty".to_string());
3595    }
3596    let lines: Vec<&str> = text.lines().collect();
3597    let mut resolved: Vec<(usize, Option<String>)> = Vec::new();
3598    let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
3599    for (hash, line_hint, new) in edits {
3600        let matches: Vec<usize> = lines
3601            .iter()
3602            .enumerate()
3603            .filter(|(i, l)| line_hash(l) == *hash || legacy_line_hash(i + 1, l) == *hash)
3604            .map(|(i, _)| i)
3605            .collect();
3606        let idx = match matches.as_slice() {
3607            [] => {
3608                return Err(format!(
3609                    "hash {hash} not found — read the file again, it may have changed"
3610                ));
3611            }
3612            [idx] => *idx,
3613            _ => line_hint
3614                .and_then(|line| line.checked_sub(1))
3615                .filter(|idx| matches.contains(idx))
3616                .ok_or_else(|| {
3617                    format!(
3618                        "hash {hash} matches multiple lines — include the 1-based line from the latest read"
3619                    )
3620                })?,
3621        };
3622        if !seen.insert(idx) {
3623            return Err(format!(
3624                "hash {hash} targets a line already edited by another entry in this call"
3625            ));
3626        }
3627        resolved.push((idx, new.clone()));
3628    }
3629
3630    let mut diff = String::new();
3631    for (idx, new) in &resolved {
3632        let _ = write!(diff, "\n- {}", lines[*idx]);
3633        if let Some(t) = new {
3634            for l in t.lines() {
3635                let _ = write!(diff, "\n+ {l}");
3636            }
3637        }
3638    }
3639
3640    // Apply highest index first so earlier (lower) indices, still unprocessed,
3641    // stay valid regardless of how many lines an edit adds or removes.
3642    let mut apply_order = resolved;
3643    apply_order.sort_by_key(|b| std::cmp::Reverse(b.0));
3644    let mut out: Vec<String> = lines.iter().map(std::string::ToString::to_string).collect();
3645    for (idx, new) in apply_order {
3646        match new {
3647            None => {
3648                out.remove(idx);
3649            }
3650            Some(t) => {
3651                let replacement: Vec<String> = t.lines().map(str::to_string).collect();
3652                out.splice(idx..=idx, replacement);
3653            }
3654        }
3655    }
3656    let mut new_text = out.join("\n");
3657    if text.ends_with('\n') && !out.is_empty() {
3658        new_text.push('\n');
3659    }
3660    Ok((new_text, diff))
3661}
3662
3663/// Compare two complete file contents using git's unified-diff renderer. The
3664/// app files are not Git worktrees, so `--no-index` gives the same useful diff
3665/// without creating repository metadata or changing either file.
3666fn unified_diff(app: &str, path: &str, current: &str, candidate: &str) -> String {
3667    if current == candidate {
3668        return "no changes".to_string();
3669    }
3670    let dir = std::env::temp_dir().join(format!("nexus-diff-{}", uuid::Uuid::new_v4()));
3671    let before = dir.join("before");
3672    let after = dir.join("after");
3673    let result = (|| {
3674        std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create diff workspace: {e}"))?;
3675        std::fs::write(&before, current)
3676            .map_err(|e| format!("cannot write current snapshot: {e}"))?;
3677        std::fs::write(&after, candidate)
3678            .map_err(|e| format!("cannot write candidate snapshot: {e}"))?;
3679        let output = std::process::Command::new("git")
3680            .arg("diff")
3681            .arg("--no-index")
3682            .arg("--no-ext-diff")
3683            .arg("--unified=3")
3684            .arg(&before)
3685            .arg(&after)
3686            .output()
3687            .map_err(|e| format!("cannot run git diff: {e}"))?;
3688        match output.status.code() {
3689            Some(0) => Ok("no changes".to_string()),
3690            Some(1) => {
3691                let old_label = format!("a/{app}/{path}");
3692                let new_label = format!("b/{app}/{path}");
3693                let before_path = before.to_string_lossy();
3694                let after_path = after.to_string_lossy();
3695                let diff = String::from_utf8_lossy(&output.stdout)
3696                    .replace(before_path.as_ref(), &old_label)
3697                    .replace(after_path.as_ref(), &new_label);
3698                Ok(diff)
3699            }
3700            _ => Err(String::from_utf8_lossy(&output.stderr).trim().to_string()),
3701        }
3702    })();
3703    let _ = std::fs::remove_dir_all(&dir);
3704    result.unwrap_or_else(|e| format!("diff failed: {e}"))
3705}
3706
3707/// Apply the app root's `.gitignore` to reads and searches. Covers the common
3708/// Git ignore forms without adding a dependency: comments, negation, directory
3709/// rules, anchored paths, and `*`/`?` globs.
3710fn app_path_ignored(root: &std::path::Path, path: &std::path::Path) -> bool {
3711    if path.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
3712        return true;
3713    }
3714    let Ok(rel) = path.strip_prefix(root) else {
3715        return false;
3716    };
3717    let rel = rel
3718        .to_string_lossy()
3719        .replace(std::path::MAIN_SEPARATOR, "/");
3720    let Ok(rules) = std::fs::read_to_string(root.join(".gitignore")) else {
3721        return false;
3722    };
3723    let mut ignored = false;
3724    for raw in rules.lines() {
3725        let line = raw.trim();
3726        if line.is_empty() || line.starts_with('#') {
3727            continue;
3728        }
3729        let (negated, pattern) = match line.strip_prefix('!') {
3730            Some(pattern) => (true, pattern),
3731            None => (false, line),
3732        };
3733        let directory_rule = pattern.ends_with('/');
3734        let pattern = pattern.trim_start_matches('/').trim_end_matches('/');
3735        let matches = if directory_rule {
3736            let mut prefix = String::new();
3737            let parts: Vec<&str> = rel.split('/').collect();
3738            parts[..parts.len().saturating_sub(1)].iter().any(|part| {
3739                if !prefix.is_empty() {
3740                    prefix.push('/');
3741                }
3742                prefix.push_str(part);
3743                gitignore_pattern_matches(pattern, &prefix)
3744            })
3745        } else {
3746            gitignore_pattern_matches(pattern, &rel)
3747        };
3748        if matches {
3749            ignored = !negated;
3750        }
3751    }
3752    ignored
3753}
3754
3755fn gitignore_pattern_matches(pattern: &str, relative_path: &str) -> bool {
3756    if pattern.contains('/') {
3757        wildcard_match(pattern, relative_path)
3758    } else {
3759        relative_path
3760            .split('/')
3761            .any(|part| wildcard_match(pattern, part))
3762    }
3763}
3764
3765fn wildcard_match(pattern: &str, text: &str) -> bool {
3766    let Some(p) = pattern.chars().next() else {
3767        return text.is_empty();
3768    };
3769    let Some(t) = text.chars().next() else {
3770        return p == '*';
3771    };
3772    match p {
3773        '*' => {
3774            wildcard_match(&pattern[p.len_utf8()..], text)
3775                || wildcard_match(pattern, &text[t.len_utf8()..])
3776        }
3777        '?' => wildcard_match(&pattern[p.len_utf8()..], &text[t.len_utf8()..]),
3778        _ if p == t => wildcard_match(&pattern[p.len_utf8()..], &text[t.len_utf8()..]),
3779        _ => false,
3780    }
3781}
3782
3783/// Recursively collect `(relpath, line)` matches for a lowercase substring
3784/// pattern, skipping dependency/venv dirs and unreadable (binary) files.
3785fn grep_dir(
3786    root: &std::path::Path,
3787    dir: &std::path::Path,
3788    pattern: &str,
3789    out: &mut Vec<(String, usize)>,
3790) {
3791    let Ok(rd) = std::fs::read_dir(dir) else {
3792        return;
3793    };
3794    let mut entries: Vec<_> = rd.filter_map(std::result::Result::ok).collect();
3795    entries.sort_by_key(std::fs::DirEntry::file_name);
3796    for entry in entries {
3797        let path = entry.path();
3798        if app_path_ignored(root, &path) {
3799            continue;
3800        }
3801        let name = entry.file_name();
3802        if path.is_dir() {
3803            // node_modules/.venv/.git are dependencies; dist/ is derived
3804            // build output (framework apps) — never search inside any of them.
3805            if name != "node_modules" && name != ".venv" && name != ".git" && name != "dist" {
3806                grep_dir(root, &path, pattern, out);
3807            }
3808        } else if let Ok(text) = std::fs::read_to_string(&path) {
3809            let rel = path.strip_prefix(root).unwrap_or(&path).display();
3810            for (i, line) in text.lines().enumerate() {
3811                if line.to_lowercase().contains(pattern) {
3812                    out.push((rel.to_string(), i + 1));
3813                }
3814            }
3815        }
3816    }
3817}
3818
3819/// Run a command with a timeout, kill-on-drop, and no shell. Returns the
3820/// raw output; spawn failures name the missing program.
3821async fn run_cmd(
3822    program: &std::ffi::OsStr,
3823    args: &[&std::ffi::OsStr],
3824    dir: &std::path::Path,
3825    secs: u64,
3826) -> Result<std::process::Output, String> {
3827    run_cmd_env(program, args, dir, secs, &[]).await
3828}
3829
3830/// Like `run_cmd` but with extra environment variables.
3831async fn run_cmd_env(
3832    program: &std::ffi::OsStr,
3833    args: &[&std::ffi::OsStr],
3834    dir: &std::path::Path,
3835    secs: u64,
3836    envs: &[(&str, &str)],
3837) -> Result<std::process::Output, String> {
3838    let mut cmd = tokio::process::Command::new(program);
3839    cmd.args(args).current_dir(dir).kill_on_drop(true);
3840    for (k, v) in envs {
3841        cmd.env(k, v);
3842    }
3843    let fut = cmd.output();
3844    match tokio::time::timeout(std::time::Duration::from_secs(secs), fut).await {
3845        Err(_) => Err(format!(
3846            "{} timed out after {secs}s",
3847            program.to_string_lossy()
3848        )),
3849        Ok(Err(e)) => Err(format!("cannot run {}: {e}", program.to_string_lossy())),
3850        Ok(Ok(out)) => Ok(out),
3851    }
3852}
3853
3854/// Command output as tool-result text: stdout, then stderr, then a non-zero
3855/// exit code — truncated so a chatty script can't flood the context.
3856fn format_output(out: &std::process::Output) -> String {
3857    let mut s = String::from(String::from_utf8_lossy(&out.stdout).trim_end());
3858    let err = String::from_utf8_lossy(&out.stderr);
3859    if !err.trim().is_empty() {
3860        if !s.is_empty() {
3861            s.push('\n');
3862        }
3863        s.push_str("stderr:\n");
3864        s.push_str(err.trim_end());
3865    }
3866    let mut lines: Vec<&str> = s.lines().collect();
3867    if lines.len() > 200 {
3868        lines.truncate(200);
3869        lines.push("… (output truncated)");
3870    }
3871    let mut s = lines.join("\n");
3872    if s.chars().count() > 8000 {
3873        s = s.chars().take(8000).collect();
3874        s.push_str("\n… (output truncated)");
3875    }
3876    if !out.status.success() {
3877        let code = out
3878            .status
3879            .code()
3880            .map_or_else(|| "killed".to_string(), |c| c.to_string());
3881        if !s.is_empty() {
3882            s.push('\n');
3883        }
3884        let _ = write!(s, "exit code: {code}");
3885    }
3886    if s.is_empty() {
3887        s = "(no output)".to_string();
3888    }
3889    s
3890}
3891
3892/// The python interpreter of a skill's own `.venv`, creating the venv (and
3893/// installing `requirements.txt` if the skill ships one) on first use.
3894/// Everything stays inside the skill's directory — nothing global.
3895async fn ensure_venv(skill_dir: &std::path::Path) -> Result<PathBuf, String> {
3896    let python = skill_dir.join(".venv/bin/python");
3897    if python.exists() {
3898        return Ok(python);
3899    }
3900    std::fs::create_dir_all(skill_dir)
3901        .map_err(|e| format!("cannot create {}: {e}", skill_dir.display()))?;
3902    // Corrupt venv from a system Python upgrade — nuke it and recreate.
3903    if skill_dir.join(".venv").exists() {
3904        std::fs::remove_dir_all(skill_dir.join(".venv"))
3905            .map_err(|e| format!("cannot remove corrupt venv: {e}"))?;
3906    }
3907    let out = run_cmd(
3908        "python3".as_ref(),
3909        &["-m".as_ref(), "venv".as_ref(), ".venv".as_ref()],
3910        skill_dir,
3911        120,
3912    )
3913    .await?;
3914    if !out.status.success() {
3915        return Err(format!("venv creation failed:\n{}", format_output(&out)));
3916    }
3917    if skill_dir.join("requirements.txt").exists() {
3918        let out = run_cmd(
3919            python.as_os_str(),
3920            &[
3921                "-m".as_ref(),
3922                "pip".as_ref(),
3923                "install".as_ref(),
3924                "-r".as_ref(),
3925                "requirements.txt".as_ref(),
3926            ],
3927            skill_dir,
3928            300,
3929        )
3930        .await?;
3931        if !out.status.success() {
3932            return Err(format!(
3933                "pip install -r requirements.txt failed:\n{}",
3934                format_output(&out)
3935            ));
3936        }
3937    }
3938    Ok(python)
3939}
3940
3941/// Package names an installer may see: no flags, no whitespace — they land
3942/// in cmd directly, so a leading `-` would become an option injection.
3943fn validate_packages(pkgs: &[String]) -> Result<(), String> {
3944    if pkgs.is_empty() {
3945        return Err("no packages given".to_string());
3946    }
3947    for p in pkgs {
3948        if p.is_empty() || p.starts_with('-') || p.chars().any(char::is_whitespace) {
3949            return Err(format!("invalid package name: {p:?}"));
3950        }
3951    }
3952    Ok(())
3953}
3954
3955#[derive(Debug)]
3956struct SearchHit {
3957    title: String,
3958    url: String,
3959    snippet: String,
3960}
3961
3962#[derive(Deserialize)]
3963struct SearxngResponse {
3964    #[serde(default)]
3965    results: Vec<SearxngResult>,
3966}
3967
3968#[derive(Deserialize)]
3969struct SearxngResult {
3970    title: String,
3971    url: String,
3972    #[serde(default)]
3973    content: String,
3974}
3975
3976/// Shared by backends that send a request and expect a JSON body back: send,
3977/// raise on a non-2xx status, then deserialize. `DuckDuckGo` scrapes HTML
3978/// instead of parsing JSON, so it doesn't use this helper.
3979async fn send_and_parse<T: serde::de::DeserializeOwned>(
3980    req: reqwest::RequestBuilder,
3981) -> anyhow::Result<T> {
3982    req.send()
3983        .await?
3984        .error_for_status()?
3985        .json::<T>()
3986        .await
3987        .map_err(Into::into)
3988}
3989
3990/// `SearXNG`'s JSON API needs `search: formats: [html, json]` enabled in the
3991/// instance's `settings.yml` — off by default. A misconfigured instance
3992/// surfaces as an HTML/error response here, which `error_for_status`/`json`
3993/// turns into a readable error for the model rather than a silent empty result.
3994async fn searxng_search(
3995    client: &reqwest::Client,
3996    base_url: &str,
3997    query: &str,
3998    recency: Option<&str>,
3999) -> anyhow::Result<Vec<SearchHit>> {
4000    let mut req = client
4001        .get(format!("{base_url}/search"))
4002        .query(&[("q", query), ("format", "json")]);
4003    if let Some(r) = recency {
4004        req = req.query(&[("time_range", r)]);
4005    }
4006    let resp = send_and_parse::<SearxngResponse>(req).await?;
4007    Ok(resp
4008        .results
4009        .into_iter()
4010        .take(8)
4011        .map(|r| SearchHit {
4012            title: r.title,
4013            url: r.url,
4014            snippet: r.content,
4015        })
4016        .collect())
4017}
4018
4019#[derive(Deserialize)]
4020struct LangsearchResponse {
4021    data: Option<LangsearchData>,
4022}
4023
4024#[derive(Deserialize)]
4025struct LangsearchData {
4026    #[serde(rename = "webPages")]
4027    web_pages: Option<LangsearchWebPages>,
4028}
4029
4030#[derive(Deserialize)]
4031struct LangsearchWebPages {
4032    #[serde(default)]
4033    value: Vec<LangsearchResult>,
4034}
4035
4036#[derive(Deserialize)]
4037struct LangsearchResult {
4038    name: String,
4039    url: String,
4040    #[serde(default)]
4041    snippet: String,
4042}
4043
4044/// `LangSearch` (<https://langsearch.com)>: free-tier hosted search API, no card
4045/// required. More reliable than scraping `DuckDuckGo` when the service is
4046/// reachable; auto mode falls back when its endpoint is unavailable.
4047async fn langsearch_search(
4048    client: &reqwest::Client,
4049    key: &str,
4050    query: &str,
4051    recency: Option<&str>,
4052) -> anyhow::Result<Vec<SearchHit>> {
4053    let mut body = serde_json::json!({ "query": query, "count": 8 });
4054    if let Some(r) = recency {
4055        // LangSearch's freshness values are Bing-style camelCase.
4056        let freshness = match r {
4057            "day" => "oneDay",
4058            "week" => "oneWeek",
4059            "month" => "oneMonth",
4060            _ => "oneYear",
4061        };
4062        body["freshness"] = serde_json::json!(freshness);
4063    }
4064    let req = client
4065        .post("https://api.langsearch.com/v1/web-search")
4066        .bearer_auth(key)
4067        .json(&body);
4068    let resp = send_and_parse::<LangsearchResponse>(req).await?;
4069    Ok(resp
4070        .data
4071        .and_then(|d| d.web_pages)
4072        .map(|w| w.value)
4073        .unwrap_or_default()
4074        .into_iter()
4075        .map(|r| SearchHit {
4076            title: r.name,
4077            url: r.url,
4078            snippet: r.snippet,
4079        })
4080        .collect())
4081}
4082
4083/// Zero-setup fallback used when no `SearXNG` instance is configured: scrapes
4084/// `DuckDuckGo`'s plain HTML search page (no JS, no API, no key) the same way
4085/// LM Studio/Open `WebUI`'s built-in `DuckDuckGo` tools do. Unofficial — `DuckDuckGo`
4086/// can change this markup or rate-limit it at any time; `SearXNG` is the more
4087/// durable option if this stops working for you.
4088async fn duckduckgo_search(
4089    client: &reqwest::Client,
4090    query: &str,
4091) -> anyhow::Result<Vec<SearchHit>> {
4092    let html = client
4093        .get("https://html.duckduckgo.com/html/")
4094        .header("User-Agent", "Mozilla/5.0 (compatible; nexus-chat)")
4095        .query(&[("q", query)])
4096        .send()
4097        .await?
4098        .error_for_status()?
4099        .text()
4100        .await?;
4101    if html.contains("anomaly-modal") || html.contains("challenge-form") {
4102        anyhow::bail!("DuckDuckGo returned an anti-bot challenge")
4103    }
4104    Ok(parse_ddg_html(&html).into_iter().take(8).collect())
4105}
4106
4107/// Last-resort zero-setup fallback. Brave's HTML endpoint currently remains
4108/// usable when `DuckDuckGo` serves its bot challenge, so keep this behind the
4109/// configured/API backends and only use it in auto mode.
4110async fn brave_search(client: &reqwest::Client, query: &str) -> anyhow::Result<Vec<SearchHit>> {
4111    let response = client
4112        .get("https://search.brave.com/search")
4113        .header(
4114            "User-Agent",
4115            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36",
4116        )
4117        .query(&[("q", query)])
4118        .timeout(std::time::Duration::from_secs(20))
4119        .send()
4120        .await?;
4121    let status = response.status();
4122    let html = response.text().await?;
4123    if !status.is_success() {
4124        anyhow::bail!("Brave returned HTTP {status}")
4125    }
4126    let hits = parse_brave_html(&html);
4127    if hits.is_empty() && html.to_ascii_lowercase().contains("captcha") {
4128        anyhow::bail!("Brave returned an anti-bot challenge")
4129    }
4130    Ok(hits.into_iter().take(8).collect())
4131}
4132
4133/// Extract readable text from a fetched body: PDF (by content-type or
4134/// `%PDF` magic bytes) via `pdf-extract`, otherwise treated as HTML.
4135/// PDF extraction failures degrade to an explanatory string rather than
4136/// erroring the whole fetch — a scanned/malformed PDF shouldn't kill the
4137/// searcher's tool call.
4138fn extract_pdf_or_html(bytes: &[u8], content_type: &str) -> String {
4139    let looks_like_pdf =
4140        content_type.to_lowercase().contains("application/pdf") || bytes.starts_with(b"%PDF");
4141    if looks_like_pdf {
4142        return match pdf_extract::extract_text_from_mem(bytes) {
4143            Ok(text) => text.trim().to_string(),
4144            Err(e) => format!("[could not extract PDF text: {e}]"),
4145        };
4146    }
4147    let html = String::from_utf8_lossy(bytes);
4148    strip_html_to_text(&html)
4149}
4150
4151/// GET `url` and return its readable text. Capped at 2MB of raw body and a
4152/// 30s timeout — a research searcher agent shouldn't be able to wedge on a
4153/// pathological page.
4154async fn fetch_url_text(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
4155    let resp = client
4156        .get(url)
4157        .header("User-Agent", "Mozilla/5.0 (compatible; nexus-chat)")
4158        .timeout(std::time::Duration::from_secs(30))
4159        .send()
4160        .await?
4161        .error_for_status()?;
4162    let content_type = resp
4163        .headers()
4164        .get(reqwest::header::CONTENT_TYPE)
4165        .and_then(|v| v.to_str().ok())
4166        .unwrap_or("")
4167        .to_string();
4168    let bytes = resp.bytes().await?;
4169    let capped = &bytes[..bytes.len().min(2_000_000)];
4170    Ok(extract_pdf_or_html(capped, &content_type))
4171}
4172
4173/// Whether `url` points at a `YouTube` watch page (long or short form).
4174fn is_youtube_url(url: &str) -> bool {
4175    let Ok(u) = reqwest::Url::parse(url) else {
4176        return false;
4177    };
4178    matches!(u.host_str(), Some(h) if h == "youtube.com" || h.ends_with(".youtube.com") || h == "youtu.be")
4179}
4180
4181/// Pull the first caption track's `baseUrl` out of a `YouTube` watch page's
4182/// embedded JSON (`ytInitialData`/`ytInitialPlayerResponse`). The value is
4183/// JSON-string-escaped (`\/` and `\uXXXX`); unescape just enough to get a
4184/// usable URL — a full JSON parse isn't needed for one field.
4185fn parse_caption_track_url(watch_page_html: &str) -> Option<String> {
4186    let marker = "\"baseUrl\":\"";
4187    let idx = watch_page_html.find(marker)? + marker.len();
4188    let end = watch_page_html[idx..].find('"')? + idx;
4189    let raw = &watch_page_html[idx..end];
4190    Some(raw.replace("\\/", "/").replace("\\u0026", "&"))
4191}
4192
4193/// Join a `YouTube` timedtext XML transcript's `<text>` cue contents with
4194/// spaces into one plain-text string (no timing/markup kept — this is fed
4195/// to a research searcher, not rendered as captions).
4196fn strip_timedtext_xml(xml: &str) -> String {
4197    split_tag_blocks(xml, "text")
4198        .iter()
4199        .map(|inner| html_unescape_entities(inner))
4200        .collect::<Vec<_>>()
4201        .join(" ")
4202}
4203
4204/// Minimal HTML entity unescaping for cue text (`&amp;` last, so it doesn't
4205/// double-unescape compound entities — same ordering as `src/extract.rs`'s
4206/// `xml_unescape`).
4207fn html_unescape_entities(s: &str) -> String {
4208    s.replace("&lt;", "<")
4209        .replace("&gt;", ">")
4210        .replace("&quot;", "\"")
4211        .replace("&#39;", "'")
4212        .replace("&amp;", "&")
4213}
4214
4215/// Fetch a `YouTube` video's transcript via the keyless timedtext endpoint:
4216/// scrape the watch page for a caption track URL, fetch it, and join the
4217/// cue text. Falls back to the normal page scrape when no caption track is
4218/// found (private/no-captions videos still return something searchable).
4219async fn fetch_youtube_transcript(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
4220    let watch_html = client
4221        .get(url)
4222        .header("User-Agent", "Mozilla/5.0 (compatible; nexus-chat)")
4223        .timeout(std::time::Duration::from_secs(30))
4224        .send()
4225        .await?
4226        .error_for_status()?
4227        .text()
4228        .await?;
4229    let Some(track_url) = parse_caption_track_url(&watch_html) else {
4230        return Ok(strip_html_to_text(&watch_html));
4231    };
4232    let xml = client
4233        .get(&track_url)
4234        .timeout(std::time::Duration::from_secs(30))
4235        .send()
4236        .await?
4237        .error_for_status()?
4238        .text()
4239        .await?;
4240    Ok(strip_timedtext_xml(&xml))
4241}
4242
4243/// Pull `(title, url, snippet)` hits out of a `DuckDuckGo` HTML results page.
4244/// Each result is `<a class="result__a" href="...uddg=<url>...">title</a>`
4245/// followed by `<a class="result__snippet" ...>snippet</a>`.
4246fn parse_ddg_html(html: &str) -> Vec<SearchHit> {
4247    let mut hits = Vec::new();
4248    let mut pos = 0;
4249    while let Some(rel) = html[pos..].find("class=\"result__a\"") {
4250        let marker_at = pos + rel;
4251        let tag_start = html[..marker_at].rfind('<').unwrap_or(marker_at);
4252        let Some(gt) = html[marker_at..].find('>') else {
4253            break;
4254        };
4255        let tag = &html[tag_start..marker_at + gt];
4256        let text_start = marker_at + gt + 1;
4257        let Some(close_rel) = html[text_start..].find("</a>") else {
4258            break;
4259        };
4260        let title = strip_tags(&html[text_start..text_start + close_rel]);
4261        pos = text_start + close_rel + 4;
4262
4263        let Some(href) = extract_attr(tag, "href") else {
4264            continue;
4265        };
4266        let Some(url) = resolve_ddg_href(&href) else {
4267            continue;
4268        };
4269        let snippet = find_snippet(html, pos);
4270        if !title.is_empty() {
4271            hits.push(SearchHit {
4272                title,
4273                url,
4274                snippet,
4275            });
4276        }
4277    }
4278    hits
4279}
4280
4281/// Pull result cards from Brave's server-rendered HTML. This intentionally
4282/// relies only on stable semantic class names and returns ordinary links, so
4283/// callers can use the same formatter/citation path as API-backed results.
4284fn parse_brave_html(html: &str) -> Vec<SearchHit> {
4285    const TITLE_MARKER: &str = "<div class=\"title search-snippet-title";
4286    let mut hits = Vec::new();
4287    let mut pos = 0;
4288    while let Some(rel) = html[pos..].find(TITLE_MARKER) {
4289        let marker_at = pos + rel;
4290        let Some(gt_rel) = html[marker_at..].find('>') else {
4291            break;
4292        };
4293        let text_start = marker_at + gt_rel + 1;
4294        let Some(close_rel) = html[text_start..].find("</div>") else {
4295            break;
4296        };
4297        let title = strip_tags(&html[text_start..text_start + close_rel]);
4298        let title_end = text_start + close_rel + "</div>".len();
4299        let Some(anchor_start) = html[..marker_at].rfind("<a ") else {
4300            pos = title_end;
4301            continue;
4302        };
4303        let Some(anchor_gt_rel) = html[anchor_start..].find('>') else {
4304            pos = title_end;
4305            continue;
4306        };
4307        let anchor_tag = &html[anchor_start..=(anchor_start + anchor_gt_rel)];
4308        let Some(url) = extract_attr(anchor_tag, "href") else {
4309            pos = title_end;
4310            continue;
4311        };
4312        let url = html_unescape(&url);
4313        if !url.starts_with("http") || title.is_empty() {
4314            pos = title_end;
4315            continue;
4316        }
4317
4318        let segment_end = html[title_end..]
4319            .find(TITLE_MARKER)
4320            .map_or(html.len(), |end| title_end + end);
4321        let snippet = html[title_end..segment_end]
4322            .find("generic-snippet")
4323            .and_then(|generic| {
4324                let start = title_end + generic;
4325                let content = html[start..segment_end].find("class=\"content")?;
4326                let content_start = start + content;
4327                let gt = html[content_start..segment_end].find('>')?;
4328                let text_start = content_start + gt + 1;
4329                let close = html[text_start..segment_end].find("</div>")?;
4330                Some(strip_tags(&html[text_start..text_start + close]))
4331            })
4332            .unwrap_or_default();
4333        hits.push(SearchHit {
4334            title,
4335            url,
4336            snippet,
4337        });
4338        pos = title_end;
4339    }
4340    hits
4341}
4342
4343/// The snippet immediately following a result's title anchor, if any.
4344fn find_snippet(html: &str, from: usize) -> String {
4345    let marker = "class=\"result__snippet\"";
4346    let Some(rel) = html[from..].find(marker) else {
4347        return String::new();
4348    };
4349    let idx = from + rel;
4350    let Some(gt) = html[idx..].find('>') else {
4351        return String::new();
4352    };
4353    let text_start = idx + gt + 1;
4354    let Some(close) = html[text_start..].find("</a>") else {
4355        return String::new();
4356    };
4357    strip_tags(&html[text_start..text_start + close])
4358}
4359
4360/// `DuckDuckGo`'s result links redirect through `/l/?uddg=<percent-encoded-url>`.
4361fn resolve_ddg_href(href: &str) -> Option<String> {
4362    if href.contains("uddg=") {
4363        let absolute = if let Some(rest) = href.strip_prefix("//") {
4364            format!("https://{rest}")
4365        } else if href.starts_with("http") {
4366            href.to_string()
4367        } else {
4368            format!("https://duckduckgo.com{href}")
4369        };
4370        let decoded = reqwest::Url::parse(&absolute)
4371            .ok()
4372            .and_then(|url| {
4373                url.query_pairs()
4374                    .find(|(k, _)| k == "uddg")
4375                    .map(|(_, v)| v.into_owned())
4376            })
4377            .unwrap_or_default();
4378        return (!decoded.is_empty()).then_some(decoded);
4379    }
4380    if let Some(rest) = href.strip_prefix("//") {
4381        return Some(format!("https://{rest}"));
4382    }
4383    href.starts_with("http").then(|| href.to_string())
4384}
4385
4386fn extract_attr(tag: &str, name: &str) -> Option<String> {
4387    let marker = format!("{name}=\"");
4388    let idx = tag.find(&marker)? + marker.len();
4389    let end = tag[idx..].find('"')? + idx;
4390    Some(tag[idx..end].to_string())
4391}
4392
4393/// Drop HTML tags and unescape entities, for anchor text pulled out of raw markup.
4394fn strip_tags(s: &str) -> String {
4395    let mut out = String::new();
4396    let mut in_tag = false;
4397    for c in s.chars() {
4398        match c {
4399            '<' => in_tag = true,
4400            '>' => in_tag = false,
4401            _ if !in_tag => out.push(c),
4402            _ => {}
4403        }
4404    }
4405    html_unescape(out.trim())
4406}
4407
4408fn html_unescape(s: &str) -> String {
4409    s.replace("&amp;", "&")
4410        .replace("&lt;", "<")
4411        .replace("&gt;", ">")
4412        .replace("&quot;", "\"")
4413        .replace("&#x27;", "'")
4414        .replace("&#39;", "'")
4415}
4416
4417/// Remove every `<tag>...</tag>` block (case-sensitive on the lowercase tag
4418/// name callers pass, e.g. "script"/"style") including its content. An
4419/// unterminated opening tag drops the remainder of the string rather than
4420/// looping forever or panicking on a truncated/malformed fetch.
4421fn drop_tag_blocks(html: &str, tag: &str) -> String {
4422    let open = format!("<{tag}");
4423    let close = format!("</{tag}>");
4424    let mut out = String::with_capacity(html.len());
4425    let mut rest = html;
4426    loop {
4427        match rest.find(&open) {
4428            None => {
4429                out.push_str(rest);
4430                break;
4431            }
4432            Some(start) => {
4433                out.push_str(&rest[..start]);
4434                match rest[start..].find(&close) {
4435                    None => break,
4436                    Some(end_rel) => {
4437                        rest = &rest[start + end_rel + close.len()..];
4438                    }
4439                }
4440            }
4441        }
4442    }
4443    out
4444}
4445
4446/// Pull every top-level `<table>...</table>` block out of `html`, replacing
4447/// it with a markdown pipe-table rendering. Runs before the generic
4448/// tag-stripper so table structure survives; a `<table>` nested inside
4449/// another is left as inner markup (rendered as flattened text by the
4450/// generic stripper afterward) rather than recursed into — good enough for
4451/// the benchmark/pricing tables research actually hits.
4452fn render_tables_as_markdown(html: &str) -> String {
4453    let mut out = String::new();
4454    let mut rest = html;
4455    while let Some(start) = rest.find("<table") {
4456        out.push_str(&rest[..start]);
4457        let Some(tag_end) = rest[start..].find('>') else {
4458            out.push_str(&rest[start..]);
4459            return out;
4460        };
4461        let body_start = start + tag_end + 1;
4462        let Some(close_rel) = rest[body_start..].find("</table>") else {
4463            out.push_str(&rest[start..]);
4464            return out;
4465        };
4466        let body_end = body_start + close_rel;
4467        out.push_str(&table_to_markdown(&rest[body_start..body_end]));
4468        rest = &rest[body_end + "</table>".len()..];
4469    }
4470    out.push_str(rest);
4471    out
4472}
4473
4474/// Render one table's inner HTML (rows of `<tr>`, cells `<th>`/`<td>`) as a
4475/// GitHub-style pipe table. Header row = first `<tr>`'s cells; a `---`
4476/// separator follows it unconditionally (even if that row used `<td>`, not
4477/// `<th>` — most scraped tables don't bother with `<th>`).
4478fn table_to_markdown(table_html: &str) -> String {
4479    let rows: Vec<Vec<String>> = split_tag_blocks(table_html, "tr")
4480        .iter()
4481        .map(|row_html| {
4482            let mut cells: Vec<String> = split_tag_blocks(row_html, "th")
4483                .iter()
4484                .map(|c| strip_tags(c).replace('\n', " ").trim().to_string())
4485                .collect();
4486            cells.extend(
4487                split_tag_blocks(row_html, "td")
4488                    .iter()
4489                    .map(|c| strip_tags(c).replace('\n', " ").trim().to_string()),
4490            );
4491            cells
4492        })
4493        .filter(|r| !r.is_empty())
4494        .collect();
4495    if rows.is_empty() {
4496        return String::new();
4497    }
4498    let cols = rows[0].len();
4499    let mut out = String::from("\n");
4500    let _ = writeln!(out, "| {} |", rows[0].join(" | "));
4501    let _ = writeln!(out, "| {} |", vec!["---"; cols].join(" | "));
4502    for row in &rows[1..] {
4503        let _ = writeln!(out, "| {} |", row.join(" | "));
4504    }
4505    out.push('\n');
4506    out
4507}
4508
4509/// Every top-level `<tag>...</tag>` block's inner HTML, in order. Does not
4510/// recurse into nested same-named tags — a nested `<tr>` inside a cell (rare,
4511/// malformed markup) is left as part of the outer block's text.
4512fn split_tag_blocks(html: &str, tag: &str) -> Vec<String> {
4513    let open = format!("<{tag}");
4514    let close = format!("</{tag}>");
4515    let mut out = Vec::new();
4516    let mut rest = html;
4517    while let Some(start) = rest.find(&open) {
4518        let Some(tag_end) = rest[start..].find('>') else {
4519            break;
4520        };
4521        let body_start = start + tag_end + 1;
4522        let Some(close_rel) = rest[body_start..].find(&close) else {
4523            break;
4524        };
4525        let body_end = body_start + close_rel;
4526        out.push(rest[body_start..body_end].to_string());
4527        rest = &rest[body_end + close.len()..];
4528    }
4529    out
4530}
4531
4532/// HTML page body → plain readable text: drop script/style blocks, strip all
4533/// remaining tags, unescape entities, and collapse blank/whitespace-only
4534/// lines so paginated output isn't mostly empty lines.
4535fn strip_html_to_text(html: &str) -> String {
4536    let no_script = drop_tag_blocks(html, "script");
4537    let no_style = drop_tag_blocks(&no_script, "style");
4538    let with_tables = render_tables_as_markdown(&no_style);
4539    strip_tags(&with_tables)
4540        .lines()
4541        .map(str::trim)
4542        .filter(|l| !l.is_empty())
4543        .collect::<Vec<_>>()
4544        .join("\n")
4545}
4546
4547/// One scholarly-paper hit, flattened from the Semantic Scholar response.
4548struct Paper {
4549    title: String,
4550    authors: Vec<String>,
4551    year: Option<i64>,
4552    venue: Option<String>,
4553    abstract_snippet: Option<String>,
4554    citation_count: Option<i64>,
4555    url: String,
4556}
4557
4558#[derive(Deserialize)]
4559struct SemanticScholarResponse {
4560    #[serde(default)]
4561    data: Vec<SemanticScholarPaper>,
4562}
4563
4564#[derive(Deserialize)]
4565struct SemanticScholarPaper {
4566    title: String,
4567    #[serde(default)]
4568    authors: Vec<SemanticScholarAuthor>,
4569    year: Option<i64>,
4570    venue: Option<String>,
4571    #[serde(rename = "abstract")]
4572    abstract_snippet: Option<String>,
4573    #[serde(rename = "citationCount")]
4574    citation_count: Option<i64>,
4575    url: Option<String>,
4576}
4577
4578#[derive(Deserialize)]
4579struct SemanticScholarAuthor {
4580    name: String,
4581}
4582
4583/// Semantic Scholar Graph API (api.semanticscholar.org): free, keyless.
4584/// A 429 (rate limited) surfaces as an error the caller turns into
4585/// tool-result text — the model falls back to search(mode=web).
4586async fn academic_search(
4587    client: &reqwest::Client,
4588    query: &str,
4589    limit: usize,
4590) -> anyhow::Result<Vec<Paper>> {
4591    let req = client
4592        .get("https://api.semanticscholar.org/graph/v1/paper/search")
4593        .query(&[
4594            ("query", query),
4595            ("limit", &limit.min(20).to_string()),
4596            (
4597                "fields",
4598                "title,authors,year,venue,abstract,citationCount,url",
4599            ),
4600        ]);
4601    let resp = send_and_parse::<SemanticScholarResponse>(req).await?;
4602    Ok(resp
4603        .data
4604        .into_iter()
4605        .map(|p| Paper {
4606            title: p.title,
4607            authors: p.authors.into_iter().map(|a| a.name).collect(),
4608            year: p.year,
4609            venue: p.venue,
4610            abstract_snippet: p.abstract_snippet,
4611            citation_count: p.citation_count,
4612            url: p.url.unwrap_or_default(),
4613        })
4614        .collect())
4615}
4616
4617/// Numbered scholarly-paper results the model cites the same way as
4618/// `format_results`' web hits: `[n]` inline, matched against this list.
4619fn format_papers(papers: &[Paper]) -> String {
4620    papers
4621        .iter()
4622        .enumerate()
4623        .map(|(i, p)| {
4624            let mut meta = Vec::new();
4625            if !p.authors.is_empty() {
4626                meta.push(p.authors.join(", "));
4627            }
4628            if let Some(y) = p.year {
4629                meta.push(y.to_string());
4630            }
4631            if let Some(v) = &p.venue {
4632                meta.push(v.clone());
4633            }
4634            if let Some(c) = p.citation_count {
4635                meta.push(format!("{c} citations"));
4636            }
4637            let abs = p.abstract_snippet.as_deref().unwrap_or("");
4638            format!(
4639                "[{}] {}\n    {}\n    {abs}\n    {}",
4640                i + 1,
4641                p.title,
4642                meta.join(" · "),
4643                p.url
4644            )
4645        })
4646        .collect::<Vec<_>>()
4647        .join("\n\n")
4648}
4649
4650/// One discussion-forum hit (Hacker News story or Reddit post), flattened
4651/// to what the model needs to decide whether to `fetch_url` it.
4652struct DiscussionHit {
4653    title: String,
4654    url: String,
4655    meta: String,
4656}
4657
4658#[derive(Deserialize)]
4659struct HnSearchResponse {
4660    #[serde(default)]
4661    hits: Vec<HnHit>,
4662}
4663
4664#[derive(Deserialize)]
4665struct HnHit {
4666    title: Option<String>,
4667    url: Option<String>,
4668    #[serde(rename = "objectID")]
4669    object_id: String,
4670    #[serde(default)]
4671    points: i64,
4672    #[serde(default, rename = "num_comments")]
4673    num_comments: i64,
4674}
4675
4676async fn hn_search(client: &reqwest::Client, query: &str) -> anyhow::Result<Vec<DiscussionHit>> {
4677    let req = client
4678        .get("https://hn.algolia.com/api/v1/search")
4679        .query(&[("query", query), ("tags", "story")]);
4680    let resp = send_and_parse::<HnSearchResponse>(req).await?;
4681    Ok(resp
4682        .hits
4683        .into_iter()
4684        .take(8)
4685        .map(|h| {
4686            let url = h
4687                .url
4688                .unwrap_or_else(|| format!("https://news.ycombinator.com/item?id={}", h.object_id));
4689            DiscussionHit {
4690                title: h.title.unwrap_or_else(|| "(untitled)".to_string()),
4691                url,
4692                meta: format!("{} points, {} comments", h.points, h.num_comments),
4693            }
4694        })
4695        .collect())
4696}
4697
4698#[derive(Deserialize)]
4699struct RedditSearchResponse {
4700    data: RedditListing,
4701}
4702
4703#[derive(Deserialize)]
4704struct RedditListing {
4705    #[serde(default)]
4706    children: Vec<RedditChild>,
4707}
4708
4709#[derive(Deserialize)]
4710struct RedditChild {
4711    data: RedditPost,
4712}
4713
4714#[derive(Deserialize)]
4715struct RedditPost {
4716    title: String,
4717    permalink: String,
4718    subreddit: String,
4719    #[serde(default)]
4720    score: i64,
4721}
4722
4723async fn reddit_search(
4724    client: &reqwest::Client,
4725    query: &str,
4726) -> anyhow::Result<Vec<DiscussionHit>> {
4727    let req = client
4728        .get("https://www.reddit.com/search.json")
4729        .header("User-Agent", "Mozilla/5.0 (compatible; nexus-chat)")
4730        .query(&[("q", query), ("sort", "relevance"), ("limit", "8")]);
4731    let resp = send_and_parse::<RedditSearchResponse>(req).await?;
4732    Ok(resp
4733        .data
4734        .children
4735        .into_iter()
4736        .map(|c| DiscussionHit {
4737            title: c.data.title,
4738            url: format!("https://reddit.com{}", c.data.permalink),
4739            meta: format!("r/{}, {} upvotes", c.data.subreddit, c.data.score),
4740        })
4741        .collect())
4742}
4743
4744fn is_reddit_url(url: &str) -> bool {
4745    reqwest::Url::parse(url)
4746        .ok()
4747        .and_then(|url| url.host_str().map(str::to_ascii_lowercase))
4748        .is_some_and(|host| host == "reddit.com" || host.ends_with(".reddit.com"))
4749}
4750
4751/// Numbered discussion results, HN first then Reddit — same `[n]` citation
4752/// convention as `format_results`/`format_papers`.
4753fn format_discussion_hits(hn: &[DiscussionHit], reddit: &[DiscussionHit]) -> String {
4754    hn.iter()
4755        .chain(reddit.iter())
4756        .enumerate()
4757        .map(|(i, h)| format!("[{}] {}\n    {}\n    {}", i + 1, h.title, h.meta, h.url))
4758        .collect::<Vec<_>>()
4759        .join("\n\n")
4760}
4761
4762/// HN (Algolia) + Reddit search run concurrently; either backend failing
4763/// independently still returns the other's hits. If both APIs are blocked,
4764/// use Brave's HTML index for Reddit links rather than silently reporting no
4765/// results.
4766async fn discussion_search(client: &reqwest::Client, query: &str) -> String {
4767    let (hn, reddit) = tokio::join!(hn_search(client, query), reddit_search(client, query));
4768    let hn = hn.unwrap_or_default();
4769    let reddit = reddit.unwrap_or_default();
4770    if !hn.is_empty() || !reddit.is_empty() {
4771        return format_discussion_hits(&hn, &reddit);
4772    }
4773
4774    let fallback_query = format!("site:reddit.com {query}");
4775    let fallback = brave_search(client, &fallback_query)
4776        .await
4777        .unwrap_or_default();
4778    let reddit: Vec<DiscussionHit> = fallback
4779        .into_iter()
4780        .filter(|hit| is_reddit_url(&hit.url))
4781        .take(8)
4782        .map(|hit| DiscussionHit {
4783            title: hit.title,
4784            url: hit.url,
4785            meta: "Reddit web result (API unavailable)".to_string(),
4786        })
4787        .collect();
4788    if reddit.is_empty() {
4789        "no results".to_string()
4790    } else {
4791        format_discussion_hits(&[], &reddit)
4792    }
4793}
4794
4795/// Normalize a source URL for dedup: lowercase the host only (path/query
4796/// case is preserved — some servers are case-sensitive there), strip
4797/// `utm_*`/`fbclid` query params, and drop a trailing `/` and any fragment.
4798/// Unparseable input (not actually a URL) is returned unchanged so it still
4799/// participates in a plain string-equality dedup.
4800pub fn normalize_url(url: &str) -> String {
4801    let Ok(mut u) = reqwest::Url::parse(url) else {
4802        return url.to_string();
4803    };
4804    u.set_fragment(None);
4805    let kept: Vec<(String, String)> = u
4806        .query_pairs()
4807        .filter(|(k, _)| k != "fbclid" && !k.starts_with("utm_"))
4808        .map(|(k, v)| (k.into_owned(), v.into_owned()))
4809        .collect();
4810    if kept.is_empty() {
4811        u.set_query(None);
4812    } else {
4813        let q = kept
4814            .iter()
4815            .map(|(k, v)| format!("{k}={v}"))
4816            .collect::<Vec<_>>()
4817            .join("&");
4818        u.set_query(Some(&q));
4819    }
4820    if let Some(h) = u.host_str().map(str::to_lowercase) {
4821        let _ = u.set_host(Some(&h));
4822    }
4823    if u.path().ends_with('/') && u.path() != "/" {
4824        let trimmed = u.path().trim_end_matches('/').to_string();
4825        u.set_path(&trimmed);
4826    }
4827    let mut s = u.to_string();
4828    if let Some(stripped) = s.strip_suffix('/') {
4829        s = stripped.to_string();
4830    }
4831    s
4832}
4833
4834/// Collapse duplicate cited sources across a set of Searcher findings.
4835/// Each finding may end with a `Sources:` block of `N. url` lines (see
4836/// `SEARCHER_PROMPT` in research.rs); a source line whose normalized URL
4837/// already appeared in an earlier finding is dropped from later ones so
4838/// the Synthesizer doesn't see the same source cited from every angle.
4839/// Non-source lines are untouched.
4840pub fn dedup_source_lines(findings: &[String]) -> Vec<String> {
4841    let mut seen = std::collections::HashSet::new();
4842    findings
4843        .iter()
4844        .map(|f| {
4845            let mut out_lines = Vec::new();
4846            let mut in_sources = false;
4847            for line in f.lines() {
4848                if line.trim().eq_ignore_ascii_case("Sources:") {
4849                    in_sources = true;
4850                    out_lines.push(line.to_string());
4851                    continue;
4852                }
4853                if in_sources
4854                    && let Some((_, url)) = line.trim().split_once(['.', ')'])
4855                    && !seen.insert(normalize_url(url.trim()))
4856                {
4857                    continue; // dup — drop this line
4858                }
4859                out_lines.push(line.to_string());
4860            }
4861            out_lines.join("\n")
4862        })
4863        .collect()
4864}
4865
4866/// Every normalized URL cited in a set of findings' `Sources:` blocks —
4867/// what gets linked into a research session's source bundle.
4868pub fn cited_url_norms(findings: &[String]) -> Vec<String> {
4869    let mut out = Vec::new();
4870    for f in findings {
4871        let mut in_sources = false;
4872        for line in f.lines() {
4873            if line.trim().eq_ignore_ascii_case("Sources:") {
4874                in_sources = true;
4875                continue;
4876            }
4877            if in_sources && let Some((_, url)) = line.trim().split_once(['.', ')']) {
4878                let url = url.trim();
4879                if !url.is_empty() {
4880                    out.push(normalize_url(url));
4881                }
4882            }
4883        }
4884    }
4885    out
4886}
4887
4888/// Rewrite `query` with `site:`/`-site:` terms — backend-agnostic (every
4889/// engine this app talks to honors Google-style site filters), so
4890/// `include_domains`/`exclude_domains`/`blocked_domains` need no per-backend
4891/// plumbing beyond this string rewrite.
4892pub fn rewrite_query_with_domains(query: &str, include: &[String], exclude: &[String]) -> String {
4893    let mut q = query.to_string();
4894    for d in include {
4895        let _ = write!(q, " site:{d}");
4896    }
4897    for d in exclude {
4898        let _ = write!(q, " -site:{d}");
4899    }
4900    q
4901}
4902
4903/// Perplexity-style numbered results the model cites inline as `[n]`.
4904fn format_results(hits: &[SearchHit]) -> String {
4905    hits.iter()
4906        .enumerate()
4907        .map(|(i, h)| format!("[{}] {}\n    {}\n    {}", i + 1, h.title, h.url, h.snippet))
4908        .collect::<Vec<_>>()
4909        .join("\n\n")
4910}
4911
4912// ── Video generation helpers ──────────────────────────────────────────────────
4913
4914/// Try to read an image file from the files directory. Accepts the id as-is or
4915/// with a `.png` extension appended.
4916fn resolve_image(files_dir: &std::path::Path, id: &str) -> Option<Vec<u8>> {
4917    if !valid_relative_path(id) {
4918        return None;
4919    }
4920    let direct = files_dir.join(id);
4921    std::fs::read(&direct)
4922        .ok()
4923        .or_else(|| std::fs::read(files_dir.join(format!("{id}.png"))).ok())
4924}
4925
4926/// Extract a single frame from a video via ffmpeg subprocess. When `use_eof`
4927/// is true, extracts the last frame (-sseof). Returns whether ffmpeg ran
4928/// successfully.
4929fn extract_ffmpeg_frame(
4930    video_path: &std::path::Path,
4931    output_path: &std::path::Path,
4932    use_eof: bool,
4933) -> bool {
4934    let mut cmd = std::process::Command::new("ffmpeg");
4935    cmd.arg("-y");
4936    if use_eof {
4937        cmd.arg("-sseof").arg("-0.1");
4938    } else {
4939        cmd.arg("-ss").arg("0");
4940    }
4941    cmd.arg("-i")
4942        .arg(video_path)
4943        .arg("-vframes")
4944        .arg("1")
4945        .arg("-f")
4946        .arg("image2")
4947        .arg(output_path)
4948        .stdout(std::process::Stdio::null())
4949        .stderr(std::process::Stdio::null())
4950        .status()
4951        .is_ok_and(|s| s.success())
4952}
4953
4954/// Read the `_video_refs.json` reference registry from the files directory.
4955/// Returns an empty JSON object `{}` if the file doesn't exist.
4956fn read_video_refs(files_dir: &std::path::Path) -> serde_json::Value {
4957    let path = files_dir.join("_video_refs.json");
4958    std::fs::read_to_string(&path)
4959        .ok()
4960        .and_then(|s| serde_json::from_str(&s).ok())
4961        .unwrap_or_else(|| serde_json::json!({}))
4962}
4963
4964/// Write the reference registry to `_video_refs.json` in the files directory.
4965fn write_video_refs(files_dir: &std::path::Path, refs: &serde_json::Value) -> anyhow::Result<()> {
4966    let path = files_dir.join("_video_refs.json");
4967    let json = serde_json::to_string_pretty(refs)?;
4968    std::fs::write(&path, json)?;
4969    Ok(())
4970}
4971
4972/// Check whether ffmpeg is available on $PATH.
4973fn ffmpeg_available() -> bool {
4974    std::process::Command::new("ffmpeg")
4975        .arg("-version")
4976        .stdout(std::process::Stdio::null())
4977        .stderr(std::process::Stdio::null())
4978        .status()
4979        .is_ok_and(|s| s.success())
4980}
4981
4982/// Resolve named character and location references to image data from the
4983/// `_video_refs.json` registry.
4984fn resolve_named_references(
4985    files_dir: &std::path::Path,
4986    character_refs: &[String],
4987    location_refs: &[String],
4988) -> Vec<Vec<u8>> {
4989    let refs = read_video_refs(files_dir);
4990    let mut images = Vec::new();
4991    for name in character_refs.iter().chain(location_refs.iter()) {
4992        if let Some(entry) = refs.get(name)
4993            && let Some(image_id) = entry.get("image_id").and_then(|id| id.as_str())
4994            && let Some(data) = resolve_image(files_dir, image_id)
4995        {
4996            images.push(data);
4997        }
4998    }
4999    images
5000}
5001
5002/// Build an ffmpeg `crop` + `scale` filter string for a camera move preset.
5003/// `margin` (0–1) controls how much of the frame edge is revealed (pan) or
5004/// how much the frame zooms in (dolly). 0.15 = 15% movement/zoom.
5005fn build_camera_filter(move_type: &str, margin: f64) -> String {
5006    // t = time in sec, du = input duration in sec, iw/ih = input width/height
5007    match move_type {
5008        "dolly_in" => {
5009            // Start full frame, end zoomed in centered
5010            let zoom = margin; // e.g. 0.15 → 15% zoom
5011            format!(
5012                "crop=w='iw-(iw*{zoom})*t/du':h='ih-(ih*{zoom})*t/du':x='(iw-w)/2':y='(ih-h)/2',scale=iw:ih"
5013            )
5014        }
5015        "dolly_out" => {
5016            let zoom = margin;
5017            format!(
5018                "crop=w='iw-(iw*{zoom})*(1-t/du)':h='ih-(ih*{zoom})*(1-t/du)':x='(iw-w)/2':y='(ih-h)/2',scale=iw:ih"
5019            )
5020        }
5021        "pan_left" => {
5022            // Crop window slides from right to left
5023            let m = margin.max(0.05);
5024            format!(
5025                "crop=w='iw*(1-{m})':h='ih*(1-{m})':x='(iw-w)*(1-t/du)':y='(ih-h)/2',scale=iw:ih"
5026            )
5027        }
5028        "pan_right" => {
5029            let m = margin.max(0.05);
5030            format!("crop=w='iw*(1-{m})':h='ih*(1-{m})':x='(iw-w)*t/du':y='(ih-h)/2',scale=iw:ih")
5031        }
5032        "tilt_up" => {
5033            let m = margin.max(0.05);
5034            format!(
5035                "crop=w='iw*(1-{m})':h='ih*(1-{m})':x='(iw-w)/2':y='(ih-h)*(1-t/du)',scale=iw:ih"
5036            )
5037        }
5038        "tilt_down" => {
5039            let m = margin.max(0.05);
5040            format!("crop=w='iw*(1-{m})':h='ih*(1-{m})':x='(iw-w)/2':y='(ih-h)*t/du',scale=iw:ih")
5041        }
5042        _ => String::new(),
5043    }
5044}
5045
5046/// Build an ffmpeg filter string for a lighting/color preset, scaled by
5047/// intensity (0–1).
5048fn build_lighting_filter(preset: &str, intensity: f64) -> String {
5049    let i = intensity.clamp(0.0, 1.0);
5050    match preset {
5051        "noir" => {
5052            let b = -0.05 * i;
5053            let c = 0.3f64.mul_add(i, 1.0);
5054            let s = 0.8f64.mul_add(-i, 1.0);
5055            format!(
5056                "eq=brightness={b:.3}:contrast={c:.3}:saturation={s:.3},colorbalance=rh={rh:.3}:gh={gh:.3}:bh={bh:.3}",
5057                b = b,
5058                c = c,
5059                s = s,
5060                rh = -0.1 * i,
5061                gh = -0.05 * i,
5062                bh = 0.1 * i
5063            )
5064        }
5065        "warm" => {
5066            let s = 0.3f64.mul_add(i, 1.0);
5067            format!(
5068                "eq=saturation={s:.3},colorbalance=rs={rs:.3}:gs={gs:.3}:bs={bs:.3}",
5069                s = s,
5070                rs = 0.1 * i,
5071                gs = 0.05 * i,
5072                bs = -0.05 * i
5073            )
5074        }
5075        "cold" => {
5076            let s = 0.1f64.mul_add(-i, 1.0);
5077            format!(
5078                "eq=saturation={s:.3},colorbalance=rs={rs:.3}:gs={gs:.3}:bs={bs:.3}",
5079                s = s,
5080                rs = -0.05 * i,
5081                gs = -0.02 * i,
5082                bs = 0.1 * i
5083            )
5084        }
5085        "vintage" => {
5086            let b = 0.03 * i;
5087            let c = 0.1f64.mul_add(-i, 1.0);
5088            let s = 0.3f64.mul_add(-i, 1.0);
5089            format!(
5090                "eq=brightness={b:.3}:contrast={c:.3}:saturation={s:.3},colorbalance=rh={rh:.3}:rm={rm:.3}:gs={gs:.3}",
5091                b = b,
5092                c = c,
5093                s = s,
5094                rh = 0.05 * i,
5095                rm = 0.05 * i,
5096                gs = -0.05 * i
5097            )
5098        }
5099        "vivid" => {
5100            let s = 0.5f64.mul_add(i, 1.0);
5101            format!("eq=saturation={s:.3}:contrast=1.1:brightness=0.02")
5102        }
5103        "bleach_bypass" => {
5104            let c = 0.4f64.mul_add(i, 1.0);
5105            let s = 0.6f64.mul_add(-i, 1.0);
5106            format!(
5107                "eq=contrast={c:.3}:saturation={s:.3}:brightness=0.02:gamma={g:.3}",
5108                c = c,
5109                s = s,
5110                g = 0.1f64.mul_add(i, 1.0)
5111            )
5112        }
5113        _ => String::new(),
5114    }
5115}
5116
5117#[cfg(test)]
5118mod tests {
5119    use super::*;
5120
5121    #[tokio::test]
5122    async fn search_sources_tool_only_appears_and_works_for_a_research_session_toolbox() {
5123        let dir = std::env::temp_dir().join(format!("nexus-searchsrc-{}", uuid::Uuid::new_v4()));
5124        std::fs::create_dir_all(&dir).unwrap();
5125        // Own directory: the attached sibling cache.db must not collide with
5126        // other tests' dbs, all of which live flat in the temp dir.
5127        let path = dir.join("nexus.db");
5128        let db = crate::db::Db::open(&path).unwrap();
5129        let space = db.default_space_id().unwrap();
5130        let s = db.create_session("t", "a/b", &space, "chat").unwrap();
5131        crate::db::cache_put(
5132            db.raw(),
5133            "https://example.com/a",
5134            "https://example.com/a",
5135            None,
5136            "rust borrow checker notes",
5137        )
5138        .unwrap();
5139        db.add_session_sources(&s.id, &["https://example.com/a".to_string()])
5140            .unwrap();
5141
5142        let tb = ToolBox::new(
5143            PathBuf::new(),
5144            None,
5145            None,
5146            "auto".to_string(),
5147            Vec::new(),
5148            Some(path.clone()),
5149            None,
5150            None,
5151        );
5152        assert!(!tb.defs().iter().any(|d| d.name == "research_lookup"));
5153
5154        let tb = tb.with_research_session(s.id.clone());
5155        assert!(tb.defs().iter().any(|d| d.name == "research_lookup"));
5156        let (result, _) = tb
5157            .run(
5158                "research_lookup",
5159                r#"{"scope":"session_sources","query":"borrow checker"}"#,
5160            )
5161            .await;
5162        assert!(result.contains("borrow checker"), "{result}");
5163
5164        let (result, _) = tb
5165            .run(
5166                "research_lookup",
5167                r#"{"scope":"session_sources","query":"quantum"}"#,
5168            )
5169            .await;
5170        assert!(result.contains("no matches"), "{result}");
5171    }
5172
5173    #[tokio::test]
5174    async fn list_citations_reports_recorded_sources_and_filters_by_query() {
5175        let (tb, db, space) = files_toolbox();
5176        db.add_citations(
5177            &space,
5178            "research-a.md",
5179            &[("https://nature.com/x".to_string(), None)],
5180        )
5181        .unwrap();
5182        let (result, _) = tb.run("research_lookup", r#"{"scope":"citations"}"#).await;
5183        assert!(result.contains("research-a.md"), "{result}");
5184        assert!(result.contains("nature.com"), "{result}");
5185
5186        let (result, _) = tb
5187            .run("research_lookup", r#"{"scope":"citations","query":"nope"}"#)
5188            .await;
5189        assert!(result.contains("no citations"), "{result}");
5190    }
5191
5192    #[tokio::test]
5193    async fn fetch_url_serves_from_cache_when_fresh() {
5194        let dir = std::env::temp_dir().join(format!("nexus-webcache-{}", uuid::Uuid::new_v4()));
5195        std::fs::create_dir_all(&dir).unwrap();
5196        let path = dir.join("nexus.db");
5197        let db = crate::db::Db::open(&path).unwrap();
5198        let cached_body = "x".repeat(MAX_TOOL_RESULT_CHARS + 100);
5199        crate::db::cache_put(
5200            db.raw(),
5201            "https://example.com/a",
5202            "https://example.com/a",
5203            None,
5204            &cached_body,
5205        )
5206        .unwrap();
5207        let tb = ToolBox::new(
5208            PathBuf::new(),
5209            None,
5210            None,
5211            "auto".to_string(),
5212            Vec::new(),
5213            Some(path),
5214            None,
5215            None,
5216        );
5217        // A cache hit must not attempt the network — the result is the cached
5218        // text, not a "fetch failed" error.
5219        let (result, _) = tb
5220            .run("fetch_url", r#"{"url":"https://example.com/a"}"#)
5221            .await;
5222        assert_eq!(result.chars().count(), MAX_TOOL_RESULT_CHARS);
5223        assert!(result.ends_with("... (tool result truncated)"), "{result}");
5224    }
5225
5226    #[test]
5227    fn normalize_url_lowercases_host_strips_tracking_params_and_trailing_slash() {
5228        assert_eq!(
5229            normalize_url(
5230                "HTTPS://Example.COM/Page/?utm_source=x&utm_medium=y&id=1&fbclid=abc#frag"
5231            ),
5232            "https://example.com/Page?id=1"
5233        );
5234        assert_eq!(normalize_url("https://example.com/"), "https://example.com");
5235        assert_eq!(normalize_url("https://example.com"), "https://example.com");
5236        assert_eq!(normalize_url("not a url"), "not a url");
5237    }
5238
5239    #[test]
5240    fn cited_url_norms_extracts_every_sources_url() {
5241        let f = "text [1]\nSources:\n1. https://a.example/\n2. https://b.example?utm_source=x";
5242        assert_eq!(
5243            cited_url_norms(&[f.to_string()]),
5244            vec!["https://a.example", "https://b.example"]
5245        );
5246    }
5247
5248    #[test]
5249    fn dedup_source_lines_keeps_first_occurrence_of_each_normalized_url() {
5250        let a = "Finding A body. [1]\nSources:\n1. https://example.com/a\n2. https://example.com/b?utm_source=x";
5251        let b = "Finding B body. [1]\nSources:\n1. https://EXAMPLE.com/a/\n2. https://other.com/c";
5252        let out = dedup_source_lines(&[a.to_string(), b.to_string()]);
5253        assert!(out[0].contains("https://example.com/a"));
5254        assert!(out[0].contains("https://example.com/b"));
5255        // b's first line (a dup of a's [1]) is dropped; its second (new) survives.
5256        assert!(!out[1].contains("example.com/a"));
5257        assert!(out[1].contains("other.com/c"));
5258    }
5259
5260    #[test]
5261    fn rewrite_query_with_domains_appends_site_and_negated_site_terms() {
5262        let out = rewrite_query_with_domains(
5263            "rust async runtimes",
5264            &["docs.rs".into()],
5265            &["reddit.com".into(), "quora.com".into()],
5266        );
5267        assert_eq!(
5268            out,
5269            "rust async runtimes site:docs.rs -site:reddit.com -site:quora.com"
5270        );
5271        assert_eq!(rewrite_query_with_domains("q", &[], &[]), "q");
5272    }
5273
5274    #[test]
5275    fn formats_papers_as_numbered_list_with_metadata() {
5276        let papers = vec![Paper {
5277            title: "Attention Is All You Need".into(),
5278            authors: vec!["A. Vaswani".into(), "N. Shazeer".into()],
5279            year: Some(2017),
5280            venue: Some("NeurIPS".into()),
5281            abstract_snippet: Some("We propose a new architecture...".into()),
5282            citation_count: Some(90000),
5283            url: "https://www.semanticscholar.org/paper/abc".into(),
5284        }];
5285        let out = format_papers(&papers);
5286        assert!(out.contains("[1] Attention Is All You Need"));
5287        assert!(out.contains("A. Vaswani, N. Shazeer"));
5288        assert!(out.contains("2017"));
5289        assert!(out.contains("NeurIPS"));
5290        assert!(out.contains("90000 citations"));
5291        assert!(out.contains("https://www.semanticscholar.org/paper/abc"));
5292    }
5293
5294    #[test]
5295    fn format_papers_handles_missing_optional_fields() {
5296        let papers = vec![Paper {
5297            title: "Untitled Preprint".into(),
5298            authors: vec![],
5299            year: None,
5300            venue: None,
5301            abstract_snippet: None,
5302            citation_count: None,
5303            url: "https://x".into(),
5304        }];
5305        let out = format_papers(&papers);
5306        assert!(out.contains("[1] Untitled Preprint"));
5307        assert!(out.contains("https://x"));
5308    }
5309
5310    #[test]
5311    fn format_discussion_hits_numbers_hn_then_reddit_with_metadata() {
5312        let hn = vec![DiscussionHit {
5313            title: "Rust 1.90 released".to_string(),
5314            url: "https://example.com/rust-190".to_string(),
5315            meta: "312 points, 88 comments".to_string(),
5316        }];
5317        let reddit = vec![DiscussionHit {
5318            title: "What do you think of Rust 1.90?".to_string(),
5319            url: "https://reddit.com/r/rust/abc".to_string(),
5320            meta: "r/rust, 245 upvotes".to_string(),
5321        }];
5322        let text = format_discussion_hits(&hn, &reddit);
5323        assert!(text.contains("[1] Rust 1.90 released"), "{text:?}");
5324        assert!(text.contains("312 points, 88 comments"), "{text:?}");
5325        assert!(
5326            text.contains("[2] What do you think of Rust 1.90?"),
5327            "{text:?}"
5328        );
5329        assert!(text.contains("r/rust, 245 upvotes"), "{text:?}");
5330    }
5331
5332    #[test]
5333    fn format_discussion_hits_empty_both_yields_empty_string() {
5334        assert_eq!(format_discussion_hits(&[], &[]), "");
5335    }
5336
5337    #[tokio::test]
5338    async fn discussion_search_serves_from_cache_when_fresh() {
5339        let dir = std::env::temp_dir().join(format!("nexus-discache-{}", uuid::Uuid::new_v4()));
5340        std::fs::create_dir_all(&dir).unwrap();
5341        let path = dir.join("nexus.db");
5342        let db = crate::db::Db::open(&path).unwrap();
5343        let cache_key = "discussion://rust performance";
5344        let cached_response =
5345            "[1] Rust is fast\n    HN · 100 points\n    https://news.ycombinator.com/rust";
5346        crate::db::cache_put(db.raw(), cache_key, cache_key, None, cached_response).unwrap();
5347        let tb = ToolBox::new(
5348            PathBuf::new(),
5349            None,
5350            None,
5351            "auto".to_string(),
5352            Vec::new(),
5353            Some(path),
5354            None,
5355            None,
5356        );
5357        // A cache hit must not attempt the network — the result is the cached
5358        // text, not a "no results" or "search failed" error.
5359        let (result, _) = tb
5360            .run(
5361                "search",
5362                r#"{"mode":"discussion","query":"rust performance"}"#,
5363            )
5364            .await;
5365        assert!(result.contains("Rust is fast"), "{result}");
5366        assert!(
5367            result.contains("https://news.ycombinator.com/rust"),
5368            "{result}"
5369        );
5370    }
5371
5372    #[test]
5373    fn parses_semantic_scholar_response_json() {
5374        let json = r#"{"data":[
5375            {"title":"A","authors":[{"name":"X"}],"year":2020,"venue":"V","abstract":"abs","citationCount":5,"url":"https://s2/a"}
5376        ]}"#;
5377        let resp: SemanticScholarResponse = serde_json::from_str(json).unwrap();
5378        assert_eq!(resp.data.len(), 1);
5379        assert_eq!(resp.data[0].title, "A");
5380        assert_eq!(resp.data[0].authors[0].name, "X");
5381    }
5382
5383    #[test]
5384    fn formats_results_as_numbered_list() {
5385        let hits = vec![
5386            SearchHit {
5387                title: "Rust 1.90".into(),
5388                url: "https://a".into(),
5389                snippet: "release notes".into(),
5390            },
5391            SearchHit {
5392                title: "Rust blog".into(),
5393                url: "https://b".into(),
5394                snippet: "announcement".into(),
5395            },
5396        ];
5397        let out = format_results(&hits);
5398        assert!(out.starts_with("[1] Rust 1.90\n    https://a\n    release notes"));
5399        assert!(out.contains("[2] Rust blog"));
5400    }
5401
5402    #[test]
5403    fn caps_tool_results_before_context_replay() {
5404        let out = cap_tool_result("x".repeat(MAX_TOOL_RESULT_CHARS + 100));
5405        assert_eq!(out.chars().count(), MAX_TOOL_RESULT_CHARS);
5406        assert!(out.ends_with("... (tool result truncated)"));
5407        assert_eq!(cap_tool_result("short result".to_string()), "short result");
5408    }
5409
5410    #[test]
5411    fn parses_searxng_response_json() {
5412        let json = r#"{"results":[
5413            {"title":"A","url":"https://a","content":"d1"},
5414            {"title":"B","url":"https://b","content":"d2"}
5415        ]}"#;
5416        let resp: SearxngResponse = serde_json::from_str(json).unwrap();
5417        assert_eq!(resp.results.len(), 2);
5418        assert_eq!(resp.results[0].title, "A");
5419    }
5420
5421    #[test]
5422    fn missing_results_field_yields_no_hits() {
5423        let resp: SearxngResponse = serde_json::from_str(r"{}").unwrap();
5424        assert!(resp.results.is_empty());
5425    }
5426
5427    #[test]
5428    fn parses_langsearch_response_json() {
5429        let json = r#"{"code":200,"data":{"webPages":{"value":[
5430            {"name":"A","url":"https://a","snippet":"d1"},
5431            {"name":"B","url":"https://b","snippet":"d2"}
5432        ]}}}"#;
5433        let resp: LangsearchResponse = serde_json::from_str(json).unwrap();
5434        let hits = resp.data.unwrap().web_pages.unwrap().value;
5435        assert_eq!(hits.len(), 2);
5436        assert_eq!(hits[0].name, "A");
5437    }
5438
5439    #[test]
5440    fn missing_langsearch_data_yields_no_hits() {
5441        let resp: LangsearchResponse = serde_json::from_str(r#"{"code":200}"#).unwrap();
5442        assert!(resp.data.is_none());
5443    }
5444
5445    #[tokio::test]
5446    async fn explicit_choice_errors_clearly_when_unconfigured_instead_of_swapping() {
5447        let tb = ToolBox::new(
5448            PathBuf::new(),
5449            None,
5450            None,
5451            "langsearch".to_string(),
5452            Vec::new(),
5453            None,
5454            None,
5455            None,
5456        );
5457        let err = tb.search("test", None, &[], &[]).await.unwrap_err();
5458        assert!(
5459            err.to_string()
5460                .contains("LangSearch selected but no API key")
5461        );
5462
5463        let tb = ToolBox::new(
5464            PathBuf::new(),
5465            None,
5466            None,
5467            "searxng".to_string(),
5468            Vec::new(),
5469            None,
5470            None,
5471            None,
5472        );
5473        let err = tb.search("test", None, &[], &[]).await.unwrap_err();
5474        assert!(
5475            err.to_string()
5476                .contains("SearXNG selected but no instance URL")
5477        );
5478    }
5479
5480    #[tokio::test]
5481    async fn auto_reaches_searxng_when_configured_instead_of_bailing() {
5482        // "auto" with only a SearXNG URL set must attempt it (proven by a
5483        // connection-level error, not the langsearch-key or no-backend message).
5484        let tb = ToolBox::new(
5485            PathBuf::new(),
5486            Some("http://127.0.0.1:1".to_string()),
5487            None,
5488            "auto".to_string(),
5489            Vec::new(),
5490            None,
5491            None,
5492            None,
5493        );
5494        // SearXNG is attempted first; if it is unavailable, auto mode may
5495        // still succeed through the zero-setup DuckDuckGo fallback.
5496        if let Err(err) = tb.search("test", None, &[], &[]).await {
5497            let msg = err.to_string();
5498            assert!(!msg.contains("no search backend configured"));
5499            assert!(!msg.contains("API key"));
5500            assert!(msg.contains("SearXNG"), "{msg}");
5501        }
5502    }
5503
5504    #[test]
5505    fn resolves_uddg_redirect_href() {
5506        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage&rut=abc";
5507        assert_eq!(
5508            resolve_ddg_href(href).as_deref(),
5509            Some("https://example.com/page")
5510        );
5511    }
5512
5513    #[test]
5514    fn resolves_protocol_relative_href_without_uddg() {
5515        assert_eq!(
5516            resolve_ddg_href("//example.com/x").as_deref(),
5517            Some("https://example.com/x")
5518        );
5519    }
5520
5521    #[test]
5522    fn resolve_ddg_href_decodes_plus_as_space() {
5523        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa+b";
5524        assert_eq!(
5525            resolve_ddg_href(href).as_deref(),
5526            Some("https://example.com/a b")
5527        );
5528    }
5529
5530    #[test]
5531    fn strip_tags_drops_markup_and_unescapes_entities() {
5532        assert_eq!(strip_tags("<b>Rust</b> &amp; friends"), "Rust & friends");
5533    }
5534
5535    #[test]
5536    fn drop_tag_blocks_removes_script_and_style_content() {
5537        let html =
5538            "<p>keep</p><script>var x = 1;</script><style>.a{color:red}</style><p>also keep</p>";
5539        let no_script = drop_tag_blocks(html, "script");
5540        assert!(!no_script.contains("var x"));
5541        assert!(no_script.contains("also keep"));
5542        let no_style = drop_tag_blocks(&no_script, "style");
5543        assert!(!no_style.contains("color:red"));
5544        assert!(no_style.contains("keep"));
5545    }
5546
5547    #[test]
5548    fn drop_tag_blocks_handles_unterminated_tag_by_dropping_the_remainder() {
5549        // A truncated fetch (or malformed page) shouldn't panic or infinite-loop.
5550        let html = "<p>keep</p><script>var x = 1;";
5551        let out = drop_tag_blocks(html, "script");
5552        assert_eq!(out, "<p>keep</p>");
5553    }
5554
5555    #[test]
5556    fn strip_html_to_text_drops_tags_scripts_styles_and_blank_lines() {
5557        let html = "<html><head><style>body{}</style><script>track();</script></head>\
5558                     <body>\n\n<h1>Title</h1>\n<p>Some   text</p>\n\n\n<p>More</p></body></html>";
5559        let text = strip_html_to_text(html);
5560        assert!(!text.contains("track()"));
5561        assert!(!text.contains("body{}"));
5562        assert!(text.contains("Title"));
5563        assert!(text.contains("More"));
5564        // No blank lines left over from stripped block-level tags.
5565        assert!(!text.contains("\n\n"));
5566    }
5567
5568    #[test]
5569    fn strip_html_to_text_renders_table_as_markdown_pipe_table() {
5570        let html = "<body><p>Intro</p><table>\
5571            <tr><th>Model</th><th>Score</th></tr>\
5572            <tr><td>A</td><td>91</td></tr>\
5573            <tr><td>B</td><td>88</td></tr>\
5574            </table><p>Outro</p></body>";
5575        let text = strip_html_to_text(html);
5576        assert!(text.contains("| Model | Score |"), "{text:?}");
5577        assert!(text.contains("| --- | --- |"), "{text:?}");
5578        assert!(text.contains("| A | 91 |"), "{text:?}");
5579        assert!(text.contains("| B | 88 |"), "{text:?}");
5580        assert!(text.contains("Intro"));
5581        assert!(text.contains("Outro"));
5582    }
5583
5584    #[test]
5585    fn strip_html_to_text_flattens_nested_tables_without_recursing() {
5586        let html = "<table><tr><td>outer<table><tr><td>inner</td></tr></table></td></tr></table>";
5587        // Must not panic or infinite-loop; nested content just degrades to flattened text.
5588        let text = strip_html_to_text(html);
5589        assert!(text.contains("outer"));
5590        assert!(text.contains("inner"));
5591    }
5592
5593    #[test]
5594    fn is_youtube_url_matches_watch_and_short_links() {
5595        assert!(is_youtube_url("https://www.youtube.com/watch?v=abc123"));
5596        assert!(is_youtube_url("https://youtu.be/abc123"));
5597        assert!(!is_youtube_url("https://example.com/watch?v=abc123"));
5598        assert!(!is_youtube_url("https://notyoutube.com/watch?v=abc123"));
5599        assert!(!is_youtube_url("https://evilyoutube.com/watch?v=abc123"));
5600        assert!(is_youtube_url("https://m.youtube.com/watch?v=abc123"));
5601    }
5602
5603    #[test]
5604    fn parse_caption_track_url_finds_the_first_baseurl_in_captiontracks() {
5605        let page = r#"var ytInitialData = {"captions":{"playerCaptionsTracklistRenderer":
5606            {"captionTracks":[{"baseUrl":"https:\/\/www.youtube.com\/api\/timedtext?v=abc&lang=en","name":{}}]}}};"#;
5607        let url = parse_caption_track_url(page).expect("should find a track");
5608        assert_eq!(url, "https://www.youtube.com/api/timedtext?v=abc&lang=en");
5609    }
5610
5611    #[test]
5612    fn parse_caption_track_url_none_when_no_captions_present() {
5613        assert!(parse_caption_track_url("var ytInitialData = {};").is_none());
5614    }
5615
5616    #[test]
5617    fn strip_timedtext_xml_joins_cue_text_with_spaces() {
5618        let xml = r#"<transcript><text start="0" dur="2">Hello there</text><text start="2" dur="3">world &amp; friends</text></transcript>"#;
5619        assert_eq!(strip_timedtext_xml(xml), "Hello there world & friends");
5620    }
5621
5622    #[test]
5623    fn fetch_url_text_extracts_pdf_when_content_type_is_pdf() {
5624        let bytes = crate::extract::pdf_with_pages(&["HELLO FROM PDF"]);
5625        let text = extract_pdf_or_html(&bytes, "application/pdf");
5626        assert!(text.contains("HELLO FROM PDF"), "{text:?}");
5627    }
5628
5629    #[test]
5630    fn extract_pdf_or_html_falls_back_to_html_for_non_pdf_content_type() {
5631        let html = b"<html><body><p>hi there</p></body></html>";
5632        let text = extract_pdf_or_html(html, "text/html; charset=utf-8");
5633        assert_eq!(text, "hi there");
5634    }
5635
5636    #[test]
5637    fn extract_pdf_or_html_detects_pdf_by_magic_bytes_even_without_content_type() {
5638        let bytes = crate::extract::pdf_with_pages(&["MAGIC BYTES PDF"]);
5639        let text = extract_pdf_or_html(&bytes, "");
5640        assert!(text.contains("MAGIC BYTES PDF"), "{text:?}");
5641    }
5642
5643    #[test]
5644    fn parses_ddg_html_result_block() {
5645        let html = r#"
5646            <div class="result">
5647              <h2 class="result__title">
5648                <a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Frust&rut=x">Rust <b>1.90</b> released</a>
5649              </h2>
5650              <a class="result__snippet" href="...">The <b>Rust</b> team announces version 1.90.</a>
5651            </div>
5652            <div class="result">
5653              <h2 class="result__title">
5654                <a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fblog&rut=y">Rust blog</a>
5655              </h2>
5656              <a class="result__snippet" href="...">Announcement post.</a>
5657            </div>
5658        "#;
5659        let hits = parse_ddg_html(html);
5660        assert_eq!(hits.len(), 2);
5661        assert_eq!(hits[0].title, "Rust 1.90 released");
5662        assert_eq!(hits[0].url, "https://example.com/rust");
5663        assert_eq!(hits[0].snippet, "The Rust team announces version 1.90.");
5664        assert_eq!(hits[1].url, "https://example.com/blog");
5665    }
5666
5667    #[test]
5668    fn parses_brave_html_result_card() {
5669        let html = r#"
5670            <div class="snippet" data-type="web">
5671              <a href="https://example.com/project" class="svelte l1">
5672                <div class="title search-snippet-title line-clamp-1">Example <strong>Project</strong></div>
5673                <div class="generic-snippet"><div class="content desktop-default-regular">A useful <b>project</b>.</div></div>
5674              </a>
5675            </div>
5676        "#;
5677        let hits = parse_brave_html(html);
5678        assert_eq!(hits.len(), 1);
5679        assert_eq!(hits[0].title, "Example Project");
5680        assert_eq!(hits[0].url, "https://example.com/project");
5681        assert_eq!(hits[0].snippet, "A useful project.");
5682    }
5683
5684    fn files_toolbox() -> (ToolBox, crate::db::Db, String) {
5685        // A real temp-file db (the toolbox opens its own connection by path).
5686        // Own directory: the attached sibling cache.db must not collide with
5687        // other tests' dbs, all of which live flat in the temp dir.
5688        let dir = std::env::temp_dir().join(format!("nexus-tools-{}", uuid::Uuid::new_v4()));
5689        std::fs::create_dir_all(&dir).unwrap();
5690        let path = dir.join("nexus.db");
5691        let db = crate::db::Db::open(&path).unwrap();
5692        let space = db.default_space_id().unwrap();
5693        let id = db.upsert_file(&space, "report.md", "h", 1, "ok").unwrap();
5694        let text: String = (1..=250)
5695            .map(|i| format!("line {i}"))
5696            .collect::<Vec<_>>()
5697            .join("\n");
5698        db.set_file_chunks(&id, &crate::extract::chunk_lines(&text))
5699            .unwrap();
5700        let tb = ToolBox::new(
5701            PathBuf::new(),
5702            None,
5703            None,
5704            "auto".to_string(),
5705            Vec::new(),
5706            None,
5707            Some(FilesCtx {
5708                db_path: path,
5709                space_id: space.clone(),
5710                embedder: None,
5711            }),
5712            None,
5713        );
5714        (tb, db, space)
5715    }
5716
5717    fn skills_toolbox() -> (ToolBox, PathBuf) {
5718        let dir = std::env::temp_dir().join(format!("nexus-skills-tb-{}", uuid::Uuid::new_v4()));
5719        std::fs::create_dir_all(dir.join("t")).unwrap();
5720        std::fs::write(
5721            dir.join("t/SKILL.md"),
5722            "---\nname: t\ndescription: d\n---\nx",
5723        )
5724        .unwrap();
5725        let tb = ToolBox::new(
5726            dir.clone(),
5727            None,
5728            None,
5729            "auto".to_string(),
5730            Vec::new(),
5731            None,
5732            None,
5733            None,
5734        );
5735        (tb, dir)
5736    }
5737
5738    #[tokio::test]
5739    async fn public_script_files_actions_are_confined_and_hash_based() {
5740        let (mut tb, dir) = skills_toolbox();
5741        let scripts = dir.join("space-scripts");
5742        tb.space_scripts_dir = scripts.clone();
5743        let (result, _) = tb
5744            .run(
5745                "script_files",
5746                r#"{"action":"write","path":"nested/tool.sh","content":"echo hi"}"#,
5747            )
5748            .await;
5749        assert!(result.contains("wrote nested/tool.sh"), "{result}");
5750        let (result, _) = tb
5751            .run(
5752                "script_files",
5753                r#"{"action":"read","path":"nested/tool.sh"}"#,
5754            )
5755            .await;
5756        assert!(result.contains("echo hi"), "{result}");
5757        let hash = line_hash("echo hi");
5758        let (result, _) = tb
5759            .run(
5760                "script_files",
5761                &format!(r#"{{"action":"edit","path":"nested/tool.sh","edits":[{{"hash":"{hash}","new":"echo bye"}}]}}"#),
5762            )
5763            .await;
5764        assert!(result.contains("edited nested/tool.sh"), "{result}");
5765        let (result, _) = tb
5766            .run("script_files", r#"{"action":"read","path":"../escape.sh"}"#)
5767            .await;
5768        assert!(result.contains("invalid path"), "{result}");
5769    }
5770
5771    #[tokio::test]
5772    async fn run_script_runs_sh_with_args_and_reports_exit_code() {
5773        let (tb, dir) = skills_toolbox();
5774        std::fs::write(dir.join("t/go.sh"), "echo \"hi $1\"\nexit 3\n").unwrap();
5775        let (result, status) = tb
5776            .run(
5777                "run_script",
5778                r#"{"skill":"t","path":"go.sh","args":["there"]}"#,
5779            )
5780            .await;
5781        assert!(status.contains("Running t/go.sh"));
5782        assert!(result.contains("hi there"), "{result}");
5783        assert!(result.contains("exit code: 3"), "{result}");
5784    }
5785
5786    #[tokio::test]
5787    async fn run_script_is_confined_and_names_missing_scripts() {
5788        let (tb, _) = skills_toolbox();
5789        let (result, _) = tb
5790            .run("run_script", r#"{"skill":"t","path":"../evil.sh"}"#)
5791            .await;
5792        assert!(result.contains("invalid"), "{result}");
5793        let (result, _) = tb
5794            .run("run_script", r#"{"skill":"t","path":"nope.sh"}"#)
5795            .await;
5796        assert!(result.contains("no such script"), "{result}");
5797    }
5798
5799    #[tokio::test]
5800    async fn install_packages_validates_names_and_target() {
5801        let (tb, _) = skills_toolbox();
5802        let (result, _) = tb.run("install_packages", r#"{"packages":[]}"#).await;
5803        assert!(result.contains("no packages"), "{result}");
5804        let (result, _) = tb
5805            .run(
5806                "install_packages",
5807                r#"{"packages":["--upgrade"],"skill":"t"}"#,
5808            )
5809            .await;
5810        assert!(result.contains("invalid package name"), "{result}");
5811        let (result, _) = tb
5812            .run("install_packages", r#"{"packages":["--upgrade"]}"#)
5813            .await;
5814        assert!(result.contains("invalid package name"), "{result}");
5815        let (result, _) = tb
5816            .run(
5817                "install_packages",
5818                r#"{"packages":["x"],"skill":"a","app":"b"}"#,
5819            )
5820            .await;
5821        assert!(result.contains("not both"), "{result}");
5822        let (result, _) = tb
5823            .run("install_packages", r#"{"packages":["x"],"skill":"ghost"}"#)
5824            .await;
5825        assert!(result.contains("unknown skill"), "{result}");
5826    }
5827
5828    #[tokio::test]
5829    async fn run_python_persists_and_runs() {
5830        let dir = std::env::temp_dir().join(format!("nexus-py-{}", uuid::Uuid::new_v4()));
5831        let scripts_dir = dir.join("scripts");
5832        let mut tb = ToolBox::new(
5833            dir.join("skills"),
5834            None,
5835            None,
5836            "auto".to_string(),
5837            Vec::new(),
5838            None,
5839            None,
5840            None,
5841        );
5842        tb.space_scripts_dir = scripts_dir.clone();
5843        let (result, status) = tb
5844            .run("run_python", r#"{"code":"print(2**32)","name":"test.py"}"#)
5845            .await;
5846        assert!(status.contains("Running script"), "status was {status:?}");
5847        assert!(result.contains("4294967296"), "{result}");
5848        assert!(scripts_dir.join("test.py").exists());
5849        assert!(scripts_dir.join(".venv/bin/python").exists());
5850        let _ = std::fs::remove_dir_all(&dir);
5851    }
5852
5853    #[tokio::test]
5854    async fn grep_app_finds_lines_and_skips_node_modules() {
5855        let (tb, dir) = apps_toolbox();
5856        let _ = tb.run("write_file", r#"{"app":"deck","path":"index.html","content":"<h1>slide one</h1>\n<p>quiet</p>\n<p>slide two</p>"}"#).await;
5857        let _ = tb
5858            .run(
5859                "write_file",
5860                r#"{"app":"deck","path":"js/a.js","content":"// slide logic"}"#,
5861            )
5862            .await;
5863        std::fs::create_dir_all(dir.join("deck/node_modules/x")).unwrap();
5864        std::fs::write(dir.join("deck/node_modules/x/i.js"), "slide").unwrap();
5865        let (result, _) = tb
5866            .run("grep_app", r#"{"app":"deck","pattern":"SLIDE"}"#)
5867            .await;
5868        assert!(result.contains("index.html:1,3"), "{result}");
5869        assert!(result.contains("js/a.js:1"), "{result}");
5870        assert!(!result.contains("<h1>slide one</h1>"), "{result}");
5871        assert!(!result.contains("// slide logic"), "{result}");
5872        assert!(!result.contains("node_modules"), "{result}");
5873        let (result, _) = tb
5874            .run("grep_app", r#"{"app":"deck","pattern":"zzz"}"#)
5875            .await;
5876        assert!(result.contains("no matches"), "{result}");
5877    }
5878
5879    #[tokio::test]
5880    async fn app_file_tools_respect_gitignore() {
5881        let (tb, _) = apps_toolbox();
5882        let _ = tb
5883            .run(
5884                "write_file",
5885                r#"{"app":"deck","path":".gitignore","content":"secret.txt\nprivate/\n"}"#,
5886            )
5887            .await;
5888        let _ = tb
5889            .run(
5890                "write_file",
5891                r#"{"app":"deck","path":"public.txt","content":"visible"}"#,
5892            )
5893            .await;
5894        let _ = tb
5895            .run(
5896                "write_file",
5897                r#"{"app":"deck","path":"secret.txt","content":"hidden"}"#,
5898            )
5899            .await;
5900        let _ = tb
5901            .run(
5902                "write_file",
5903                r#"{"app":"deck","path":"private/data.txt","content":"hidden"}"#,
5904            )
5905            .await;
5906
5907        let (result, _) = tb
5908            .run("grep_app", r#"{"app":"deck","pattern":"hidden"}"#)
5909            .await;
5910        assert!(result.contains("no matches"), "{result}");
5911
5912        let (result, _) = tb
5913            .run("read_app_file", r#"{"app":"deck","path":"secret.txt"}"#)
5914            .await;
5915        assert!(result.contains("ignored by .gitignore"), "{result}");
5916
5917        let (result, _) = tb
5918            .run("read_app_file", r#"{"app":"deck","path":".gitignore"}"#)
5919            .await;
5920        assert!(result.contains("ignored by .gitignore"), "{result}");
5921    }
5922
5923    #[tokio::test]
5924    async fn reads_are_line_hashed_and_edits_show_a_diff() {
5925        let (tb, _) = apps_toolbox();
5926        let _ = tb
5927            .run(
5928                "write_file",
5929                r#"{"app":"d","path":"i.html","content":"alpha\nbeta"}"#,
5930            )
5931            .await;
5932        let (result, _) = tb
5933            .run("read_app_file", r#"{"app":"d","path":"i.html"}"#)
5934            .await;
5935        let h1 = line_hash("alpha");
5936        let h2 = line_hash("beta");
5937        assert!(result.contains(&format!("    1:{h1}\talpha")), "{result}");
5938        assert!(result.contains(&format!("    2:{h2}\tbeta")), "{result}");
5939        let (result, _) = tb
5940            .run(
5941                "edit_file",
5942                &format!(
5943                    r#"{{"app":"d","path":"i.html","edits":[{{"hash":"{h2}","new":"gamma"}}]}}"#
5944                ),
5945            )
5946            .await;
5947        assert!(result.contains("- beta"), "{result}");
5948        assert!(result.contains("+ gamma"), "{result}");
5949    }
5950
5951    #[test]
5952    fn hashline_edits_survive_line_shifts_and_support_duplicate_hints() {
5953        let hash = line_hash("needle");
5954        let edits = vec![(hash, None, Some("changed".to_string()))];
5955        let (out, _) = apply_hashline_edits("inserted\nneedle", &edits).unwrap();
5956        assert_eq!(out, "inserted\nchanged");
5957
5958        let hash = line_hash("same");
5959        let edits = vec![(hash, Some(3), Some("third".to_string()))];
5960        let (out, _) = apply_hashline_edits("same\nother\nsame", &edits).unwrap();
5961        assert_eq!(out, "same\nother\nthird");
5962    }
5963
5964    #[tokio::test]
5965    async fn install_skill_rejects_bad_shorthand_without_network() {
5966        let tb = ToolBox::new(
5967            PathBuf::new(),
5968            None,
5969            None,
5970            "auto".to_string(),
5971            Vec::new(),
5972            None,
5973            None,
5974            None,
5975        );
5976        let (result, status) = tb.run("install_skill", r#"{"source":"nope"}"#).await;
5977        assert!(status.contains("Installing skill"));
5978        assert!(result.contains("invalid source"), "{result}");
5979    }
5980
5981    #[test]
5982    fn new_wires_searxng_url_and_langsearch_key() {
5983        let tb = ToolBox::new(
5984            PathBuf::new(),
5985            Some("http://localhost:8080".to_string()),
5986            Some("key-1".to_string()),
5987            "auto".to_string(),
5988            Vec::new(),
5989            None,
5990            None,
5991            None,
5992        );
5993        assert_eq!(tb.searxng_url.as_deref(), Some("http://localhost:8080"));
5994        assert_eq!(tb.langsearch_key.as_deref(), Some("key-1"));
5995        // The seam exposes the same wiring contract the App relies on.
5996        assert!(!tb.supports_images());
5997    }
5998
5999    #[test]
6000    fn defs_include_file_tools_only_when_files_exist() {
6001        let (tb, ..) = files_toolbox();
6002        let names: Vec<String> = tb.defs().iter().map(|d| d.name.clone()).collect();
6003        assert!(names.contains(&"files".to_string()));
6004
6005        let empty = ToolBox::new(
6006            PathBuf::new(),
6007            None,
6008            None,
6009            "auto".to_string(),
6010            Vec::new(),
6011            None,
6012            None,
6013            None,
6014        );
6015        let names: Vec<String> = empty.defs().iter().map(|d| d.name.clone()).collect();
6016        assert!(!names.contains(&"files".to_string()));
6017    }
6018
6019    #[test]
6020    fn fetch_url_is_always_available() {
6021        let tb = ToolBox::new(
6022            PathBuf::new(),
6023            None,
6024            None,
6025            "auto".to_string(),
6026            Vec::new(),
6027            None,
6028            None,
6029            None,
6030        );
6031        let names: Vec<String> = tb.defs().iter().map(|d| d.name.clone()).collect();
6032        assert!(names.contains(&"fetch_url".to_string()));
6033        assert!(names.contains(&"search".to_string()));
6034    }
6035
6036    #[test]
6037    fn public_definitions_have_only_consolidated_names() {
6038        let tb = ToolBox::new(
6039            PathBuf::new(),
6040            None,
6041            None,
6042            "auto".to_string(),
6043            Vec::new(),
6044            None,
6045            None,
6046            None,
6047        );
6048        let names: Vec<_> = tb.defs().into_iter().map(|def| def.name).collect();
6049        assert!(names.len() <= 9, "too many public tools: {names:?}");
6050        for old in [
6051            "skill",
6052            "skill_admin",
6053            "run_python",
6054            "run_script",
6055            "install_packages",
6056            "create_skill",
6057            "install_skill",
6058            "web_search",
6059            "academic_search",
6060            "discussion_search",
6061            "search_sources",
6062            "list_citations",
6063            "search_files",
6064            "read_file",
6065            "read_pdf_page",
6066            "app_inspect",
6067            "app_modify",
6068            "app_assets",
6069            "read_app_file",
6070            "grep_app",
6071            "write_file",
6072            "edit_file",
6073            "diff_app",
6074            "list_images",
6075            "copy_file_to_app",
6076            "copy_images_to_app",
6077            "script_files",
6078            "list_scripts",
6079            "write_script",
6080            "read_script",
6081            "edit_script",
6082            "generate_image",
6083            "generate_video",
6084            "video_transform",
6085            "video_references",
6086            "edit_video",
6087            "extract_frame",
6088            "stitch_videos",
6089            "save_reference",
6090            "list_references",
6091            "delete_reference",
6092        ] {
6093            assert!(
6094                !names.iter().any(|name| name == old),
6095                "deprecated tool advertised: {old}"
6096            );
6097        }
6098        for required in ["batch", "skills", "scripts", "search", "fetch_url"] {
6099            assert!(
6100                names.iter().any(|name| name == required),
6101                "missing {required}"
6102            );
6103        }
6104    }
6105
6106    #[test]
6107    fn unchanged_result_note_labels_the_call_and_marks_it() {
6108        let note = super::tool_result_unchanged_note("read_file", r#"{"name":"report.pdf"}"#);
6109        assert!(
6110            note.starts_with(super::TOOL_RESULT_OMITTED_PREFIX),
6111            "{note}"
6112        );
6113        assert!(note.contains("read_file report.pdf"), "{note}");
6114        assert!(note.contains("unchanged"), "{note}");
6115        // Unknown tools fall back to the bare name.
6116        let note = super::tool_result_unchanged_note("mystery_tool", "{}");
6117        assert!(note.contains("mystery_tool"), "{note}");
6118    }
6119
6120    #[test]
6121    // One row per tool — the classification table is the point.
6122    #[allow(clippy::too_many_lines)]
6123    fn read_only_tool_classification() {
6124        for (tool, args) in [
6125            ("search", r#"{"mode":"web","query":"x"}"#),
6126            ("web_search", r#"{"query":"x"}"#),
6127            ("academic_search", r#"{"query":"x"}"#),
6128            ("discussion_search", r#"{"query":"x"}"#),
6129            ("fetch_url", r#"{"url":"https://x"}"#),
6130            ("research_lookup", r#"{"scope":"citations"}"#),
6131            ("search_sources", r#"{"query":"x"}"#),
6132            ("list_citations", "{}"),
6133            ("files", r#"{"action":"read","name":"x"}"#),
6134            ("search_files", r#"{"query":"x"}"#),
6135            ("read_file", r#"{"name":"x"}"#),
6136            ("read_pdf_page", r#"{"name":"x","page":1}"#),
6137            ("app_inspect", r#"{"action":"read","app":"a","path":"x"}"#),
6138            ("read_app_file", r#"{"app":"a","path":"x"}"#),
6139            ("grep_app", r#"{"app":"a","pattern":"x"}"#),
6140            ("diff_app", r#"{"app":"a","path":"x","content":"y"}"#),
6141            ("list_images", "{}"),
6142            ("read_script", r#"{"path":"x"}"#),
6143            ("list_scripts", "{}"),
6144            ("list_references", "{}"),
6145            ("skill", r#"{"name":"t"}"#),
6146            ("skills", r#"{"action":"load","name":"t"}"#),
6147            ("scripts", r#"{"action":"list"}"#),
6148            ("scripts", r#"{"action":"read","path":"a.sh"}"#),
6149            ("app", r#"{"action":"read","app":"a","path":"x"}"#),
6150            ("app", r#"{"action":"search","app":"a","pattern":"x"}"#),
6151            ("app", r#"{"action":"list"}"#),
6152            ("media", r#"{"action":"list_references"}"#),
6153        ] {
6154            assert!(
6155                super::is_read_only_tool(tool, args),
6156                "{tool} {args} should be read-only"
6157            );
6158        }
6159        for (tool, args) in [
6160            ("batch", "{}"),
6161            (
6162                "skills",
6163                r#"{"action":"create","name":"x","description":"y"}"#,
6164            ),
6165            ("skills", r#"{"action":"install","source":"a/b"}"#),
6166            (
6167                "scripts",
6168                r#"{"action":"write","path":"a.sh","content":"x"}"#,
6169            ),
6170            ("scripts", r#"{"action":"run","path":"a.sh"}"#),
6171            (
6172                "scripts",
6173                r#"{"action":"python","code":"print(1)","name":"x.py"}"#,
6174            ),
6175            ("scripts", r#"{"action":"install","packages":["x"]}"#),
6176            (
6177                "app",
6178                r#"{"action":"write","app":"a","path":"x","content":"y"}"#,
6179            ),
6180            (
6181                "app",
6182                r#"{"action":"patch","app":"a","path":"x","edits":[{"hash":"h"}]}"#,
6183            ),
6184            (
6185                "app",
6186                r#"{"action":"copy_images","app":"a","image_ids":["i"]}"#,
6187            ),
6188            ("media", r#"{"action":"generate_image","prompt":"x"}"#),
6189            ("media", r#"{"action":"edit","video_id":"v"}"#),
6190            (
6191                "media",
6192                r#"{"action":"save_reference","name":"n","image_id":"i","description":"d"}"#,
6193            ),
6194            (
6195                "skill_admin",
6196                r#"{"action":"create","name":"x","description":"y"}"#,
6197            ),
6198            ("create_skill", r#"{"name":"x","description":"y"}"#),
6199            ("install_skill", r#"{"source":"a/b"}"#),
6200            ("run_python", r#"{"code":"print(1)","name":"x.py"}"#),
6201            ("run_script", r#"{"path":"x"}"#),
6202            ("install_packages", r#"{"packages":["x"]}"#),
6203            (
6204                "app_modify",
6205                r#"{"action":"write","app":"a","path":"x","content":"y"}"#,
6206            ),
6207            ("write_file", r#"{"app":"a","path":"x","content":"y"}"#),
6208            (
6209                "edit_file",
6210                r#"{"app":"a","path":"x","edits":[{"hash":"h"}]}"#,
6211            ),
6212            (
6213                "app_assets",
6214                r#"{"action":"copy_file","app":"a","file_name":"f"}"#,
6215            ),
6216            ("copy_file_to_app", r#"{"app":"a","file_name":"f"}"#),
6217            ("copy_images_to_app", r#"{"app":"a","image_ids":["i"]}"#),
6218            (
6219                "script_files",
6220                r#"{"action":"write","path":"a.sh","content":"x"}"#,
6221            ),
6222            ("write_script", r#"{"path":"a.sh","content":"x"}"#),
6223            ("edit_script", r#"{"path":"a.sh","edits":[{"hash":"h"}]}"#),
6224            ("generate_image", r#"{"prompt":"x"}"#),
6225            ("generate_video", r#"{"prompt":"x"}"#),
6226            ("video_transform", r#"{"action":"edit","video_id":"v"}"#),
6227            (
6228                "save_reference",
6229                r#"{"name":"n","image_id":"i","description":"d"}"#,
6230            ),
6231            ("delete_reference", r#"{"name":"n"}"#),
6232        ] {
6233            assert!(
6234                !super::is_read_only_tool(tool, args),
6235                "{tool} {args} should be mutating"
6236            );
6237        }
6238    }
6239
6240    #[tokio::test]
6241    async fn batch_runs_mixed_operations_in_order() {
6242        let (mut tb, dir) = skills_toolbox();
6243        let scripts = dir.join("space-scripts");
6244        tb.space_scripts_dir = scripts.clone();
6245        let (result, status) = tb
6246            .run(
6247                "batch",
6248                r#"{"calls":[
6249                    {"tool":"scripts","arguments":{"action":"write","path":"a.sh","content":"echo a"}},
6250                    {"tool":"scripts","arguments":{"action":"read","path":"a.sh"}},
6251                    {"tool":"skills","arguments":{"action":"load","name":"t"}}
6252                ]}"#,
6253            )
6254            .await;
6255        assert!(status.contains('3'), "status was {status:?}");
6256        assert!(result.contains("[1/3] scripts/write a.sh"), "{result}");
6257        assert!(result.contains("[2/3] scripts/read a.sh"), "{result}");
6258        assert!(result.contains("[3/3] skills/load t"), "{result}");
6259        assert!(result.contains("wrote a.sh"), "{result}");
6260        assert!(result.contains("echo a"), "{result}");
6261    }
6262
6263    #[tokio::test]
6264    async fn batch_of_reads_returns_every_result() {
6265        let (tb, _dir) = apps_toolbox();
6266        let _ = tb
6267            .run(
6268                "write_file",
6269                r#"{"app":"deck","path":"index.html","content":"<h1>hi</h1>"}"#,
6270            )
6271            .await;
6272        let (result, status) = tb
6273            .run(
6274                "batch",
6275                r#"{"calls":[
6276                    {"tool":"app","arguments":{"action":"read","app":"deck","path":"index.html"}},
6277                    {"tool":"app","arguments":{"action":"read","app":"deck","path":"index.html"}}
6278                ]}"#,
6279            )
6280            .await;
6281        assert!(status.contains('2'), "status was {status:?}");
6282        assert!(
6283            result.contains("[1/2] app/read deck/index.html"),
6284            "{result}"
6285        );
6286        assert!(
6287            result.contains("[2/2] app/read deck/index.html"),
6288            "{result}"
6289        );
6290        assert!(result.contains("<h1>hi</h1>"), "{result}");
6291    }
6292
6293    #[tokio::test]
6294    async fn batch_rejects_nested_oversized_and_empty() {
6295        let (tb, _) = skills_toolbox();
6296        let (result, _) = tb.run("batch", r#"{"calls":[]}"#).await;
6297        assert!(result.contains("non-empty"), "{result}");
6298        let (result, _) = tb
6299            .run("batch", r#"{"calls":[{"tool":"batch","arguments":{}}]}"#)
6300            .await;
6301        assert!(result.contains("nested"), "{result}");
6302        let calls: Vec<serde_json::Value> = (0..9)
6303            .map(|_| serde_json::json!({ "tool": "skill", "arguments": { "name": "t" } }))
6304            .collect();
6305        let args = serde_json::json!({ "calls": calls }).to_string();
6306        let (result, _) = tb.run("batch", &args).await;
6307        assert!(result.contains("at most 8"), "{result}");
6308    }
6309
6310    #[tokio::test]
6311    async fn batch_isolates_unknown_subcalls() {
6312        let (tb, _) = skills_toolbox();
6313        let (result, _) = tb
6314            .run(
6315                "batch",
6316                r#"{"calls":[
6317                    {"tool":"nonexistent","arguments":{}},
6318                    {"tool":"skills","arguments":{"action":"load","name":"t"}}
6319                ]}"#,
6320            )
6321            .await;
6322        assert!(result.contains("unknown tool: nonexistent"), "{result}");
6323        assert!(result.contains("[2/2] skills/load t"), "{result}");
6324    }
6325
6326    #[tokio::test]
6327    async fn consolidated_tools_reject_invalid_actions_and_missing_fields() {
6328        let tb = ToolBox::new(
6329            PathBuf::new(),
6330            None,
6331            None,
6332            "auto".to_string(),
6333            Vec::new(),
6334            None,
6335            None,
6336            None,
6337        );
6338        for (name, args, expected) in [
6339            ("skills", r#"{"action":"nope"}"#, "invalid action"),
6340            (
6341                "skills",
6342                r#"{"action":"create","name":"x"}"#,
6343                "missing required field: description",
6344            ),
6345            ("scripts", r#"{"action":"nope"}"#, "invalid action"),
6346            (
6347                "scripts",
6348                r#"{"action":"python","code":"print(1)"}"#,
6349                "missing required field: name",
6350            ),
6351            ("skill_admin", r#"{"action":"nope"}"#, "invalid action"),
6352            (
6353                "search",
6354                r#"{"mode":"web"}"#,
6355                "missing required field: query",
6356            ),
6357            (
6358                "research_lookup",
6359                r#"{"scope":"session_sources"}"#,
6360                "missing required field: query",
6361            ),
6362            ("files", r#"{"action":"nope"}"#, "invalid action"),
6363            ("app", r#"{"action":"nope"}"#, "invalid action"),
6364            (
6365                "app",
6366                r#"{"action":"read","app":"a"}"#,
6367                "missing required field: path",
6368            ),
6369            (
6370                "app_inspect",
6371                r#"{"action":"nope","app":"a"}"#,
6372                "invalid action",
6373            ),
6374            (
6375                "app_modify",
6376                r#"{"action":"nope","app":"a","path":"x"}"#,
6377                "invalid action",
6378            ),
6379            ("app_assets", r#"{"action":"nope"}"#, "invalid action"),
6380            ("script_files", r#"{"action":"nope"}"#, "invalid action"),
6381            ("media", r#"{"action":"nope"}"#, "invalid action"),
6382            (
6383                "media",
6384                r#"{"action":"save_reference","name":"x","image_id":"i"}"#,
6385                "missing required field: description",
6386            ),
6387            ("video_transform", r#"{"action":"nope"}"#, "invalid action"),
6388            (
6389                "video_references",
6390                r#"{"action":"save","name":"x"}"#,
6391                "missing required field: image_id",
6392            ),
6393        ] {
6394            let (result, status) = tb.run(name, args).await;
6395            assert_eq!(status, "invalid arguments", "{name}: {result}");
6396            assert!(result.contains(expected), "{name}: {result}");
6397        }
6398    }
6399
6400    #[tokio::test]
6401    async fn search_files_returns_ranked_snippets() {
6402        let (tb, ..) = files_toolbox();
6403        let (result, status) = tb.run("search_files", r#"{"query":"line 42"}"#).await;
6404        assert!(status.contains("Searching files"));
6405        assert!(result.contains("report.md"));
6406        assert!(result.contains("lines 41-80"));
6407        // No embedder configured → keyword search IS the primary, no fallback tag.
6408        assert!(!result.contains("keyword fallback"), "{result}");
6409    }
6410
6411    #[tokio::test]
6412    async fn public_files_actions_preserve_search_and_paging() {
6413        let (tb, ..) = files_toolbox();
6414        let (result, _) = tb
6415            .run("files", r#"{"action":"search","query":"line 42"}"#)
6416            .await;
6417        assert!(result.contains("report.md"), "{result}");
6418        let (result, _) = tb
6419            .run(
6420                "files",
6421                r#"{"action":"read","name":"report.md","offset":201}"#,
6422            )
6423            .await;
6424        assert!(result.contains("line 201"), "{result}");
6425        assert!(!result.contains("line 1"), "{result}");
6426    }
6427
6428    #[test]
6429    fn semantic_snippets_rank_truncate_and_report_none_without_vectors() {
6430        let (_, db, space) = files_toolbox();
6431        let conn = db.raw();
6432        // No vectors stored yet.
6433        assert!(semantic_snippets(conn, &space, &[1.0, 0.0]).is_none());
6434
6435        let id = db.upsert_file(&space, "notes.md", "h2", 1, "ok").unwrap();
6436        let long = "long ".repeat(200);
6437        db.set_file_chunks(
6438            &id,
6439            &[
6440                ("p1".into(), long.clone()),
6441                ("p2".into(), "short target".into()),
6442            ],
6443        )
6444        .unwrap();
6445        db.set_chunk_embeddings(&id, &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])])
6446            .unwrap();
6447
6448        let out = semantic_snippets(conn, &space, &[0.0, 1.0]).unwrap();
6449        let first = out.lines().next().unwrap();
6450        assert!(first.contains("notes.md (p2)"), "{first}");
6451        assert!(first.contains("short target"), "{first}");
6452        // The long chunk is truncated, not dumped whole.
6453        assert!(out.lines().nth(1).unwrap().len() < long.len(), "{out}");
6454    }
6455
6456    #[tokio::test]
6457    async fn read_file_is_ranged_and_capped() {
6458        let (tb, ..) = files_toolbox();
6459        let (result, _) = tb.run("read_file", r#"{"name":"report.md"}"#).await;
6460        assert!(result.contains("line 1"));
6461        assert!(result.contains("line 200"));
6462        assert!(!result.contains("line 201")); // 200-line cap
6463
6464        let (result, _) = tb
6465            .run("read_file", r#"{"name":"report.md","offset":201}"#)
6466            .await;
6467        assert!(result.contains("line 201"));
6468        assert!(result.contains("line 250"));
6469
6470        let (result, _) = tb.run("read_file", r#"{"name":"nope.md"}"#).await;
6471        assert!(result.contains("unknown file"));
6472    }
6473
6474    fn apps_toolbox() -> (ToolBox, PathBuf) {
6475        let dir = std::env::temp_dir().join(format!("nexus-apps-{}", uuid::Uuid::new_v4()));
6476        let registry = crate::appserver::AppRegistry::load(&PathBuf::from("/tmp"));
6477        let tb = ToolBox::new(
6478            PathBuf::new(),
6479            None,
6480            None,
6481            "auto".to_string(),
6482            Vec::new(),
6483            None,
6484            None,
6485            Some(AppsCtx {
6486                dir: dir.clone(),
6487                server_port: 9999,
6488                public_base: None,
6489                registry,
6490                space_name: "default".to_string(),
6491                space_id: "default".to_string(),
6492                space_db_path: PathBuf::from("/tmp/test.db"),
6493                files_dir: dir.clone(),
6494                session_id: String::new(),
6495            }),
6496        );
6497        (tb, dir)
6498    }
6499
6500    #[test]
6501    fn defs_include_app_tools_only_with_apps_ctx() {
6502        let (tb, _) = apps_toolbox();
6503        let names: Vec<String> = tb.defs().iter().map(|d| d.name.clone()).collect();
6504        {
6505            let t = "app";
6506            assert!(names.contains(&t.to_string()), "missing {t}");
6507        }
6508        // The app tool's action surface includes init/build.
6509        let defs = tb.defs();
6510        let app_def = defs.iter().find(|d| d.name == "app").unwrap();
6511        let actions = app_def
6512            .parameters
6513            .get("properties")
6514            .and_then(|p| p.get("action"))
6515            .and_then(|a| a.get("enum"))
6516            .and_then(serde_json::Value::as_array)
6517            .unwrap();
6518        let actions: Vec<&str> = actions.iter().filter_map(|a| a.as_str()).collect();
6519        for a in ["init", "build"] {
6520            assert!(actions.contains(&a), "missing {a}");
6521        }
6522        let empty = ToolBox::new(
6523            PathBuf::new(),
6524            None,
6525            None,
6526            "auto".to_string(),
6527            Vec::new(),
6528            None,
6529            None,
6530            None,
6531        );
6532        let names: Vec<String> = empty.defs().iter().map(|d| d.name.clone()).collect();
6533        assert!(!names.contains(&"app".to_string()));
6534    }
6535
6536    #[tokio::test]
6537    async fn init_scaffolds_astro_and_vite_templates() {
6538        let (tb, dir) = apps_toolbox();
6539        let (result, _) = tb
6540            .run("init_app", r#"{"app":"site","framework":"astro"}"#)
6541            .await;
6542        assert!(
6543            result.contains("scaffolded site with the astro starter"),
6544            "{result}"
6545        );
6546        assert!(result.contains("app action=build"), "{result}");
6547        assert!(
6548            result.contains("live at http://127.0.0.1:9999/"),
6549            "{result}"
6550        );
6551        assert!(dir.join("site/package.json").is_file());
6552        assert!(dir.join("site/src/pages/index.astro").is_file());
6553        assert!(dir.join("site/src/components/Counter.tsx").is_file());
6554        // Derived dirs are gitignored so the editing tools leave them alone.
6555        assert!(dir.join("site/.gitignore").is_file());
6556        let pkg = std::fs::read_to_string(dir.join("site/package.json")).unwrap();
6557        assert!(pkg.contains("astro build"), "{pkg}");
6558
6559        let (result, _) = tb
6560            .run("init_app", r#"{"app":"spa","framework":"vite-react"}"#)
6561            .await;
6562        assert!(result.contains("vite-react"), "{result}");
6563        assert!(dir.join("spa/src/App.tsx").is_file());
6564        assert!(dir.join("spa/vite.config.js").is_file());
6565
6566        // Unknown framework rejected; existing apps are never clobbered.
6567        let (result, _) = tb
6568            .run("init_app", r#"{"app":"x","framework":"svelte"}"#)
6569            .await;
6570        assert!(result.contains("unknown framework"), "{result}");
6571        let (result, _) = tb
6572            .run("init_app", r#"{"app":"site","framework":"astro"}"#)
6573            .await;
6574        assert!(result.contains("already exists"), "{result}");
6575        let _ = std::fs::remove_dir_all(&dir);
6576    }
6577
6578    #[tokio::test]
6579    async fn build_app_guides_instead_of_failing_obscurely() {
6580        let (tb, dir) = apps_toolbox();
6581        // No package.json → point the model at init.
6582        let (result, _) = tb.run("build_app", r#"{"app":"bare"}"#).await;
6583        assert!(result.contains("no package.json"), "{result}");
6584
6585        // Plain package.json without a bundler → served as-is.
6586        std::fs::create_dir_all(dir.join("plain")).unwrap();
6587        std::fs::write(
6588            dir.join("plain/package.json"),
6589            r#"{"name":"plain","private":true}"#,
6590        )
6591        .unwrap();
6592        let (result, _) = tb.run("build_app", r#"{"app":"plain"}"#).await;
6593        assert!(result.contains("no build step"), "{result}");
6594        let _ = std::fs::remove_dir_all(&dir);
6595    }
6596
6597    /// The build tool shells out to `npx`; false on machines without node.
6598    async fn npx_available() -> bool {
6599        tokio::process::Command::new("npx")
6600            .arg("--version")
6601            .output()
6602            .await
6603            .is_ok_and(|o| o.status.success())
6604    }
6605
6606    #[cfg(unix)]
6607    #[tokio::test]
6608    async fn build_runs_the_declared_tool_and_marks_dist_served() {
6609        use std::os::unix::fs::PermissionsExt;
6610        if !npx_available().await {
6611            return; // no node/npm on this machine — skip
6612        }
6613        let (tb, dir) = apps_toolbox();
6614        let app = dir.join("built");
6615        std::fs::create_dir_all(app.join("node_modules/.bin")).unwrap();
6616        std::fs::write(
6617            app.join("package.json"),
6618            r#"{"name":"built","dependencies":{"astro":"^5.0.0"}}"#,
6619        )
6620        .unwrap();
6621        // A fake `astro` bin: builds dist/ without node or the network.
6622        std::fs::write(
6623            app.join("node_modules/.bin/astro"),
6624            "#!/bin/sh\nmkdir -p dist\necho '<h1>ok</h1>' > dist/index.html\necho \"$*\" > build-args.txt\necho 'built ok'\n",
6625        )
6626        .unwrap();
6627        std::fs::set_permissions(
6628            app.join("node_modules/.bin/astro"),
6629            std::fs::Permissions::from_mode(0o755),
6630        )
6631        .unwrap();
6632
6633        let (result, _) = tb.run("build_app", r#"{"app":"built"}"#).await;
6634        assert!(result.contains("build ok"), "{result}");
6635        assert!(
6636            result.contains("live at http://127.0.0.1:9999/"),
6637            "{result}"
6638        );
6639        assert!(app.join("dist/index.html").is_file());
6640
6641        // The build ran with the app's absolute base so asset links resolve.
6642        let ctx = tb.apps.as_ref().unwrap();
6643        let uuid = ctx.registry.resolve("default", "built").unwrap();
6644        let args = std::fs::read_to_string(app.join("build-args.txt")).unwrap();
6645        assert!(args.contains(&format!("--base /{uuid}/")), "{args}");
6646
6647        // The registry now serves the app from dist/.
6648        assert_eq!(
6649            ctx.registry.lookup(&uuid).unwrap().served_from.as_deref(),
6650            Some("dist")
6651        );
6652        let _ = std::fs::remove_dir_all(&dir);
6653    }
6654
6655    #[cfg(unix)]
6656    #[tokio::test]
6657    async fn build_without_dist_output_never_marks_dist_served() {
6658        use std::os::unix::fs::PermissionsExt;
6659        if !npx_available().await {
6660            return; // no node/npm on this machine — skip
6661        }
6662        let (tb, dir) = apps_toolbox();
6663        let app = dir.join("misbuilt");
6664        std::fs::create_dir_all(app.join("node_modules/.bin")).unwrap();
6665        std::fs::write(
6666            app.join("package.json"),
6667            r#"{"name":"misbuilt","dependencies":{"astro":"^5.0.0"}}"#,
6668        )
6669        .unwrap();
6670        // A fake `astro` bin that exits 0 but never writes dist/ (as if the
6671        // framework's outDir were configured elsewhere).
6672        std::fs::write(
6673            app.join("node_modules/.bin/astro"),
6674            "#!/bin/sh\necho 'build ok'\n",
6675        )
6676        .unwrap();
6677        std::fs::set_permissions(
6678            app.join("node_modules/.bin/astro"),
6679            std::fs::Permissions::from_mode(0o755),
6680        )
6681        .unwrap();
6682
6683        let (result, _) = tb.run("build_app", r#"{"app":"misbuilt"}"#).await;
6684        assert!(result.contains("produced no dist/"), "{result}");
6685        // The app is not marked served-from-dist, so nothing 404s.
6686        let ctx = tb.apps.as_ref().unwrap();
6687        let uuid = ctx.registry.resolve("default", "misbuilt").unwrap();
6688        assert_eq!(ctx.registry.lookup(&uuid).unwrap().served_from, None);
6689        let _ = std::fs::remove_dir_all(&dir);
6690    }
6691
6692    #[tokio::test]
6693    async fn write_edit_read_round_trip_with_live_url() {
6694        let (tb, dir) = apps_toolbox();
6695        let (result, _) = tb
6696            .run(
6697                "write_file",
6698                r#"{"app":"deck","path":"index.html","content":"<h1>Hello</h1>"}"#,
6699            )
6700            .await;
6701        assert!(result.contains("wrote deck/index.html"), "{result}");
6702        assert!(
6703            result.contains("live at http://127.0.0.1:9999/"),
6704            "{result}"
6705        );
6706        assert_eq!(
6707            std::fs::read_to_string(dir.join("deck/index.html")).unwrap(),
6708            "<h1>Hello</h1>"
6709        );
6710
6711        // nested path creates parent dirs
6712        let (result, _) = tb
6713            .run(
6714                "write_file",
6715                r#"{"app":"deck","path":"js/a.js","content":"1"}"#,
6716            )
6717            .await;
6718        assert!(result.contains("wrote deck/js/a.js"), "{result}");
6719
6720        let h = line_hash("<h1>Hello</h1>");
6721        let (result, _) = tb
6722            .run(
6723                "edit_file",
6724                &format!(r#"{{"app":"deck","path":"index.html","edits":[{{"hash":"{h}","new":"<h1>Bye</h1>"}}]}}"#),
6725            )
6726            .await;
6727        assert!(result.contains("edited deck/index.html"), "{result}");
6728        assert_eq!(
6729            std::fs::read_to_string(dir.join("deck/index.html")).unwrap(),
6730            "<h1>Bye</h1>"
6731        );
6732
6733        let (result, _) = tb
6734            .run("read_app_file", r#"{"app":"deck","path":"index.html"}"#)
6735            .await;
6736        assert!(result.contains("<h1>Bye</h1>"), "{result}");
6737        assert!(result.contains("lines 1-1 of 1"), "{result}");
6738    }
6739
6740    #[tokio::test]
6741    async fn public_app_delegates_to_hashline_backend() {
6742        let (tb, dir) = apps_toolbox();
6743        let (result, _) = tb
6744            .run(
6745                "app",
6746                r#"{"action":"write","app":"public-deck","path":"index.html","content":"<h1>Hello</h1>"}"#,
6747            )
6748            .await;
6749        assert!(
6750            result.contains("live at http://127.0.0.1:9999/"),
6751            "{result}"
6752        );
6753        let (result, _) = tb
6754            .run(
6755                "app",
6756                r#"{"action":"read","app":"public-deck","path":"index.html"}"#,
6757            )
6758            .await;
6759        assert!(result.contains("<h1>Hello</h1>"), "{result}");
6760        let hash = line_hash("<h1>Hello</h1>");
6761        let (result, _) = tb
6762            .run(
6763                "app",
6764                &format!(r#"{{"action":"patch","app":"public-deck","path":"index.html","edits":[{{"hash":"{hash}","new":"<h1>Bye</h1>"}}]}}"#),
6765            )
6766            .await;
6767        assert!(result.contains("edited public-deck/index.html"), "{result}");
6768        let (result, _) = tb
6769            .run(
6770                "app",
6771                r#"{"action":"search","app":"public-deck","pattern":"bye","compact":false}"#,
6772            )
6773            .await;
6774        assert!(result.contains("index.html:1: <h1>Bye</h1>"), "{result}");
6775        assert_eq!(
6776            std::fs::read_to_string(dir.join("public-deck/index.html")).unwrap(),
6777            "<h1>Bye</h1>"
6778        );
6779    }
6780
6781    #[tokio::test]
6782    async fn consolidated_skills_scripts_and_media_delegate() {
6783        let (mut tb, dir) = skills_toolbox();
6784        let scripts_dir = dir.join("space-scripts");
6785        tb.space_scripts_dir = scripts_dir.clone();
6786        let (result, status) = tb.run("skills", r#"{"action":"load","name":"t"}"#).await;
6787        assert!(status.contains("Reading"), "{status}");
6788        assert_eq!(result, "x"); // skill body, frontmatter stripped
6789
6790        let (result, _) = tb
6791            .run(
6792                "scripts",
6793                r#"{"action":"python","code":"print(2**32)","name":"t.py"}"#,
6794            )
6795            .await;
6796        assert!(result.contains("4294967296"), "{result}");
6797        assert!(scripts_dir.join("t.py").exists());
6798
6799        let (result, status) = tb.run("media", r#"{"action":"list_references"}"#).await;
6800        assert!(status.contains("Listing"), "{status}");
6801        assert!(result.contains("no references"), "{result}");
6802        let _ = std::fs::remove_dir_all(&dir);
6803    }
6804
6805    #[tokio::test]
6806    async fn diff_app_previews_candidate_changes_without_writing() {
6807        let (tb, dir) = apps_toolbox();
6808        let _ = tb
6809            .run(
6810                "write_file",
6811                r#"{"app":"deck","path":"index.html","content":"<h1>Hello</h1>"}"#,
6812            )
6813            .await;
6814
6815        let (result, _) = tb
6816            .run(
6817                "diff_app",
6818                r#"{"app":"deck","path":"index.html","content":"<h1>Bye</h1>\n<p>New</p>"}"#,
6819            )
6820            .await;
6821
6822        assert!(result.contains("-<h1>Hello</h1>"), "{result}");
6823        assert!(result.contains("+<h1>Bye</h1>"), "{result}");
6824        assert!(result.contains("+<p>New</p>"), "{result}");
6825        assert_eq!(
6826            std::fs::read_to_string(dir.join("deck/index.html")).unwrap(),
6827            "<h1>Hello</h1>"
6828        );
6829    }
6830
6831    #[tokio::test]
6832    async fn edit_file_rejects_stale_and_duplicate_hashes() {
6833        let (tb, _) = apps_toolbox();
6834        let _ = tb
6835            .run(
6836                "write_file",
6837                r#"{"app":"a","path":"f.txt","content":"x y x"}"#,
6838            )
6839            .await;
6840
6841        // A hash for content that isn't in the file at all.
6842        let (result, _) = tb
6843            .run(
6844                "edit_file",
6845                r#"{"app":"a","path":"f.txt","edits":[{"hash":"deadbeef","new":"w"}]}"#,
6846            )
6847            .await;
6848        assert!(result.contains("not found"), "{result}");
6849
6850        // Two edits resolving to the same line in one call.
6851        let h = line_hash("x y x");
6852        let args = format!(
6853            r#"{{"app":"a","path":"f.txt","edits":[{{"hash":"{h}","new":"a"}},{{"hash":"{h}","new":"b"}}]}}"#
6854        );
6855        let (result, _) = tb.run("edit_file", &args).await;
6856        assert!(result.contains("already edited"), "{result}");
6857    }
6858
6859    #[tokio::test]
6860    async fn edit_file_can_delete_and_insert_via_multiline_replacement() {
6861        let (tb, dir) = apps_toolbox();
6862        let _ = tb
6863            .run(
6864                "write_file",
6865                r#"{"app":"a","path":"f.txt","content":"one\ntwo\nthree"}"#,
6866            )
6867            .await;
6868        let h = line_hash("two");
6869        // Delete "two" and insert an extra line after "one" in the same call.
6870        let args = format!(
6871            r#"{{"app":"a","path":"f.txt","edits":[{{"hash":"{}","new":"one\ninserted"}},{{"hash":"{h}"}}]}}"#,
6872            line_hash("one"),
6873        );
6874        let (result, _) = tb.run("edit_file", &args).await;
6875        assert!(result.contains("edited a/f.txt"), "{result}");
6876        assert_eq!(
6877            std::fs::read_to_string(dir.join("a/f.txt")).unwrap(),
6878            "one\ninserted\nthree"
6879        );
6880    }
6881
6882    #[tokio::test]
6883    async fn app_paths_are_confined() {
6884        let (tb, dir) = apps_toolbox();
6885        for args in [
6886            r#"{"app":"..","path":"f.txt","content":"x"}"#,
6887            r#"{"app":"a/b","path":"f.txt","content":"x"}"#,
6888            r#"{"app":"a","path":"../f.txt","content":"x"}"#,
6889            r#"{"app":"a","path":"/etc/f.txt","content":"x"}"#,
6890            r#"{"app":"a","path":"b/../../f.txt","content":"x"}"#,
6891            r#"{"app":"a","path":"","content":"x"}"#,
6892        ] {
6893            let (result, _) = tb.run("write_file", args).await;
6894            assert!(
6895                result.contains("invalid") || result.contains("must be relative"),
6896                "{args} -> {result}"
6897            );
6898        }
6899        assert!(!dir.join("../f.txt").exists());
6900    }
6901
6902    #[test]
6903    fn research_toolbox_offers_search_modes_and_fetch_url() {
6904        let tb = ToolBox::research(None, None, "auto".to_string(), Vec::new(), None);
6905        let names: Vec<String> = tb.defs().iter().map(|d| d.name.clone()).collect();
6906        assert_eq!(names.len(), 2, "{names:?}");
6907        assert!(names.contains(&"search".to_string()));
6908        assert!(names.contains(&"fetch_url".to_string()));
6909    }
6910
6911    #[tokio::test]
6912    async fn research_toolbox_refuses_to_run_other_tools() {
6913        let tb = ToolBox::research(None, None, "auto".to_string(), Vec::new(), None);
6914        let (result, _) = tb.run("run_python", r#"{"code":"print(1)"}"#).await;
6915        assert!(
6916            result.contains("not available in research mode"),
6917            "{result}"
6918        );
6919    }
6920
6921    #[tokio::test]
6922    async fn cache_only_toolbox_returns_not_cached_marker_on_miss_without_network() {
6923        let dir = std::env::temp_dir().join(format!("nexus-cacheonly-{}", uuid::Uuid::new_v4()));
6924        std::fs::create_dir_all(&dir).unwrap();
6925        let db_path = dir.join("db.sqlite3");
6926        // Any valid sqlite file works — fetch_cached only needs cache_get/cache_put's
6927        // table, migrated on open elsewhere in real use; here confirm the miss path
6928        // never reaches the network.
6929        let conn = rusqlite::Connection::open(&db_path).unwrap();
6930        conn.execute_batch(
6931            "CREATE TABLE web_cache (url_norm TEXT PRIMARY KEY, url TEXT NOT NULL, title TEXT, text TEXT NOT NULL, fetched_at TEXT NOT NULL);",
6932        ).unwrap();
6933        drop(conn);
6934
6935        let tb = ToolBox::research(None, None, "auto".to_string(), Vec::new(), Some(db_path))
6936            .cache_only();
6937        let (result, _status) = tb
6938            .run(
6939                "fetch_url",
6940                r#"{"url":"https://never-fetched.example/page"}"#,
6941            )
6942            .await;
6943        assert!(result.contains("not cached"), "{result}");
6944    }
6945}