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