1use 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;
19const MAX_BATCH_CALLS: usize = 8;
22const MAX_BATCH_RESULT_CHARS: usize = MAX_TOOL_RESULT_CHARS * 4;
26
27pub struct ToolBox {
28 pub skills_dir: PathBuf,
29 pub searxng_url: Option<String>,
32 pub langsearch_key: Option<String>,
34 pub search_provider: String,
37 research_only: bool,
41 pub blocked_domains: Vec<String>,
44 db_path: Option<PathBuf>,
50 research_session_id: Option<String>,
53 client: reqwest::Client,
54 files: Option<FilesCtx>,
55 apps: Option<AppsCtx>,
56 pub supports_images: bool,
60 cache_only: bool,
64 pub image_gen_backend: Option<(OpenRouter, String)>,
66 pub space_files_dir: PathBuf,
68 pub space_apps_dir: PathBuf,
69 pub space_scripts_dir: PathBuf,
72 pub session_id: String,
74 pub video_gen_backend: Option<(OpenRouter, String)>,
76}
77
78pub struct FilesCtx {
82 pub db_path: std::path::PathBuf,
83 pub space_id: String,
84 pub embedder: Option<(crate::provider::openrouter::OpenRouter, String)>,
86}
87
88pub struct AppsCtx {
92 pub dir: PathBuf,
93 pub server_port: u16,
94 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#[allow(clippy::too_many_lines)]
128fn 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
458pub trait ToolExecutor: Send + Sync + 'static {
468 fn defs(&self) -> Vec<ToolDef>;
470 fn is_read_only(&self, name: &str, args: &str) -> bool;
473 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 fn supports_images(&self) -> bool;
483 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 #[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 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 #[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 #[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 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 _ => {
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 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 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 #[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 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 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 #[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 #[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 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 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 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 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 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 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 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 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!("")
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 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 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 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 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 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, ¤t, &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 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 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 if let Some(lt) = lighting {
2787 let lf = build_lighting_filter(lt, intensity);
2788 filter_parts.push(lf);
2789 }
2790
2791 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 cap_result(result, MAX_BATCH_RESULT_CHARS)
3253 } else {
3254 cap_tool_result(result)
3255 };
3256 (result, status)
3257 }
3258}
3259
3260pub 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
3306fn 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
3386pub const TOOL_RESULT_OMITTED_PREFIX: &str = "[result omitted: ";
3394
3395pub 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
3409fn 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
3427fn 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
3440async 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; }
3460 _ => fell_back = true, }
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
3481fn 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
3510fn 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
3527fn 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
3541fn 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 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
3608fn 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
3652fn 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
3728fn 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 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
3764fn 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
3786async 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
3797async 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
3821fn 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
3859async 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 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
3908fn 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
3943async 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
3957async 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
4011async 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 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
4050async 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
4074async 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
4100fn 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
4118async 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
4140fn 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
4148fn 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
4160fn 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
4171fn html_unescape_entities(s: &str) -> String {
4175 s.replace("<", "<")
4176 .replace(">", ">")
4177 .replace(""", "\"")
4178 .replace("'", "'")
4179 .replace("&", "&")
4180}
4181
4182async 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
4210fn 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
4248fn 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
4310fn 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
4327fn 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
4360fn 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("&", "&")
4377 .replace("<", "<")
4378 .replace(">", ">")
4379 .replace(""", "\"")
4380 .replace("'", "'")
4381 .replace("'", "'")
4382}
4383
4384fn 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
4413fn 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
4441fn 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
4476fn 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
4499fn 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
4514struct 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
4550async 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
4584fn 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
4617struct 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
4718fn 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
4729async 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
4762pub 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
4801pub 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; }
4826 out_lines.push(line.to_string());
4827 }
4828 out_lines.join("\n")
4829 })
4830 .collect()
4831}
4832
4833pub 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
4855pub 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
4870fn 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
4879fn 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
4893fn 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
4921fn 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
4931fn 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
4939fn 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
4949fn 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
4969fn build_camera_filter(move_type: &str, margin: f64) -> String {
4973 match move_type {
4975 "dolly_in" => {
4976 let zoom = margin; 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 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
5013fn 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 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 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 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 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 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 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> & 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 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 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 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 & 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 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 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 let note = super::tool_result_unchanged_note("mystery_tool", "{}");
6071 assert!(note.contains("mystery_tool"), "{note}");
6072 }
6073
6074 #[test]
6075 #[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 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 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 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")); 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 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 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 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 let (result, _) = tb.run("build_app", r#"{"app":"bare"}"#).await;
6537 assert!(result.contains("no package.json"), "{result}");
6538
6539 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 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; }
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 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 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 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; }
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 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 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 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"); 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 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 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 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 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}