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