From 0cca06bb3102d3dd7afde4ce60fee927b8744676 Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 11:43:34 -0500 Subject: [PATCH 1/8] read, ls, edit, write, bash cmds --- src-tauri/Cargo.lock | 1013 ++++++++++++++++- src-tauri/Cargo.toml | 2 +- src-tauri/crates/llm/src/lib.rs | 84 +- src-tauri/src/commands/storage.rs | 2 + src-tauri/src/commands/storage/mari.rs | 768 ++++++++----- src-tauri/src/commands/storage/mari_fs.rs | 744 ++++++++++++ .../mari/components/ProfessorMariSurface.tsx | 27 +- 7 files changed, 2311 insertions(+), 329 deletions(-) create mode 100644 src-tauri/src/commands/storage/mari_fs.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b1bafb826..6a22753f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -108,6 +108,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -117,6 +126,23 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "ast_node" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -302,8 +328,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autoagents" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea33cd9c456faf15b4d753076f8e9a592b94d65a6e1886aaf07c4c13d9b6bcf" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-core", @@ -315,12 +340,12 @@ dependencies = [ [[package]] name = "autoagents-core" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa1ac1c801e98323ef3ef8470104f95348e98d976070ba6a0e4c0611a9f367c" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-llm", "autoagents-protocol", + "deno_ast", "futures", "futures-core", "futures-util", @@ -328,6 +353,8 @@ dependencies = [ "log", "ractor", "regex", + "rquickjs", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.18", @@ -342,11 +369,11 @@ dependencies = [ [[package]] name = "autoagents-derive" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710bf6e07c5def09bfaed423290a4fb3646763938910c60641821a36d6e1320" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "proc-macro2", "quote", + "schemars 0.8.22", "serde", "serde_json", "strum", @@ -356,12 +383,12 @@ dependencies = [ [[package]] name = "autoagents-llm" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afe50cb2767b514559748c23b60e815a91a24d5422fb01041fcc0b3e1babb97" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-protocol", "base64 0.22.1", + "bytes", "chrono", "dirs", "either", @@ -385,8 +412,7 @@ dependencies = [ [[package]] name = "autoagents-protocol" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831a39521bcbb57b41d7a92bd064fd662e55f59ca862340407d878192f480c21" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "serde", "serde_json", @@ -488,6 +514,25 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -518,6 +563,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -622,6 +679,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "buttplug" @@ -739,6 +799,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-str" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -773,6 +843,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "capacity_builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2d24a6dcf0cd402a21b65d35340f3a49ff3475dc5fdac91d22d2733e6641c6" +dependencies = [ + "capacity_builder_macros", + "itoa", +] + +[[package]] +name = "capacity_builder_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -812,6 +902,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -971,6 +1070,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -980,6 +1092,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1171,7 +1292,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -1291,6 +1412,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.11" @@ -1302,6 +1429,100 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + +[[package]] +name = "deno_ast" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05792fb2b77938767ffeb0853289e86501fddc84b1cdba9e5dc860c52baa2ff7" +dependencies = [ + "base64 0.22.1", + "capacity_builder", + "deno_error", + "deno_media_type", + "deno_terminal", + "dprint-swc-ext", + "percent-encoding", + "serde", + "swc_atoms", + "swc_common", + "swc_config", + "swc_config_macro", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_codegen_macros", + "swc_ecma_lexer", + "swc_ecma_loader", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_transforms_classes", + "swc_ecma_transforms_macros", + "swc_ecma_transforms_proposal", + "swc_ecma_transforms_react", + "swc_ecma_transforms_typescript", + "swc_ecma_utils", + "swc_ecma_visit", + "swc_eq_ignore_macros", + "swc_macros_common", + "swc_sourcemap", + "swc_visit", + "text_lines", + "thiserror 2.0.18", + "unicode-width", + "url", +] + +[[package]] +name = "deno_error" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" +dependencies = [ + "deno_error_macro", + "libc", +] + +[[package]] +name = "deno_error_macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deno_media_type" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debab24ecd9f4fd64aa42fb18a02dff20a97d5830b2b85b98ce70b509f790763" +dependencies = [ + "data-url", + "serde", + "url", +] + +[[package]] +name = "deno_terminal" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ba8041ae7319b3ca6a64c399df4112badcbbe0868b4517637647614bede4be" +dependencies = [ + "once_cell", + "termcolor", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1485,6 +1706,22 @@ dependencies = [ "serde", ] +[[package]] +name = "dprint-swc-ext" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33175ddb7a6d418589cab2966bd14a710b3b1139459d3d5ca9edf783c4833f4c" +dependencies = [ + "num-bigint", + "rustc-hash", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_lexer", + "swc_ecma_parser", + "text_lines", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1806,12 +2043,28 @@ dependencies = [ "num", ] +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -2265,6 +2518,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -2316,6 +2573,20 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hstr" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b94e40256e78ddd4e30490aa931bec17e65e9413a6ad11f64ec67815da9323" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash", + "serde", + "triomphe", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2589,6 +2860,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" + [[package]] name = "image" version = "0.25.10" @@ -2672,6 +2949,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -2998,7 +3287,7 @@ dependencies = [ "md-5", "nom", "nom_locate", - "rand", + "rand 0.9.4", "rangemap", "sha2", "stringprep", @@ -3292,6 +3581,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -3355,6 +3645,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3573,6 +3873,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3660,6 +3969,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "par-core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f" +dependencies = [ + "once_cell", +] + [[package]] name = "parking" version = "2.2.1" @@ -3718,14 +4036,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] @@ -3735,8 +4063,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -3746,7 +4084,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3755,20 +4106,29 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher", + "siphasher 1.0.3", ] [[package]] @@ -4011,6 +4371,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -4062,7 +4432,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -4132,13 +4502,28 @@ dependencies = [ ] [[package]] -name = "rand" -version = "0.9.4" +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -4148,9 +4533,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -4276,6 +4667,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4400,6 +4800,54 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rquickjs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" +dependencies = [ + "async-lock", + "hashbrown 0.16.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap 2.14.0", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.117", +] + +[[package]] +name = "rquickjs-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83" +dependencies = [ + "cc", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4517,6 +4965,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "same-file" version = "1.0.6" @@ -4586,6 +5040,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4626,7 +5086,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -4644,6 +5104,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -4896,6 +5362,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.3" @@ -4914,6 +5386,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "socket2" version = "0.6.3" @@ -4978,6 +5461,25 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.60.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4986,7 +5488,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4996,12 +5498,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.117", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -5046,6 +5559,387 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swc_allocator" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d7eefd2c8b228a8c73056482b2ae4b3a1071fbe07638e3b55ceca8570cc48bb" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.14.5", + "rustc-hash", +] + +[[package]] +name = "swc_atoms" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ccbe2ecad10ad7432100f878a107b1d972a8aee83ca53184d00c23a078bb8a" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "17.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "259b675d633a26d24efe3802a9d88858c918e6e8f062d3222d3aa02d56a2cf4c" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "new_debug_unreachable", + "num-bigint", + "once_cell", + "rustc-hash", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_sourcemap", + "swc_visit", + "tracing", + "unicode-width", + "url", +] + +[[package]] +name = "swc_config" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97" +dependencies = [ + "anyhow", + "bytes-str", + "indexmap 2.14.0", + "serde", + "serde_json", + "swc_config_macro", +] + +[[package]] +name = "swc_config_macro" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b416e8ce6de17dc5ea496e10c7012b35bbc0e3fef38d2e065eed936490db0b3" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_ast" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a573a0c72850dec8d4d8085f152d5778af35a2520c3093b242d2d1d50776da7c" +dependencies = [ + "bitflags 2.11.1", + "is-macro", + "num-bigint", + "once_cell", + "phf 0.11.3", + "rustc-hash", + "serde", + "string_enum", + "swc_atoms", + "swc_common", + "swc_visit", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_codegen" +version = "20.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2a6ee1ec49dda8dedeac54e4147b4e8b3f278d9bb34ab28983257a393d34ed" +dependencies = [ + "ascii", + "compact_str", + "memchr", + "num-bigint", + "once_cell", + "regex", + "rustc-hash", + "ryu-js", + "serde", + "swc_allocator", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen_macros", + "swc_sourcemap", + "tracing", +] + +[[package]] +name = "swc_ecma_codegen_macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8" +dependencies = [ + "proc-macro2", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_lexer" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e82f7747e052c6ff6e111fa4adeb14e33b46ee6e94fe5ef717601f651db48fc" +dependencies = [ + "bitflags 2.11.1", + "either", + "num-bigint", + "rustc-hash", + "seq-macro", + "serde", + "smallvec", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "tracing", +] + +[[package]] +name = "swc_ecma_loader" +version = "17.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcababb48f0d46587a0a854b2c577eb3a56fa99687de558338021e93cd2c8f5" +dependencies = [ + "anyhow", + "pathdiff", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "tracing", +] + +[[package]] +name = "swc_ecma_parser" +version = "27.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1a51af1a92cd4904c073b293e491bbc0918400a45d58227b34c961dd6f52d7" +dependencies = [ + "bitflags 2.11.1", + "either", + "num-bigint", + "phf 0.11.3", + "rustc-hash", + "seq-macro", + "serde", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_base" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f6f165578ca4fee47bd57585c1b9597c94bf4ea6591df47f2b5fa5b1883fe" +dependencies = [ + "better_scoped_tls", + "indexmap 2.14.0", + "once_cell", + "par-core", + "phf 0.11.3", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_transforms_classes" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3ab35eff4a980e02d708798ae4c35bc017612292adbffe7b7b554df772fdf5" +dependencies = [ + "swc_common", + "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_transforms_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc777288799bf6786e5200325a56e4fbabba590264a4a48a0c70b16ad0cf5cd8" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "swc_ecma_transforms_proposal" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7748d4112c87ce1885260035e4a43cebfe7661a40174b7d77a0a04760a257" +dependencies = [ + "either", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_transforms_classes", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_transforms_react" +version = "33.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03de12e38e47ac1c96ac576f793ad37a9d7b16fbf4f2203881f89152f2498682" +dependencies = [ + "base64 0.22.1", + "bytes-str", + "indexmap 2.14.0", + "once_cell", + "rustc-hash", + "serde", + "sha1", + "string_enum", + "swc_atoms", + "swc_common", + "swc_config", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_transforms_typescript" +version = "33.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4408800fdeb541fabf3659db622189a0aeb386f57b6103f9294ff19dfde4f7b0" +dependencies = [ + "bytes-str", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_transforms_base", + "swc_ecma_transforms_react", + "swc_ecma_utils", + "swc_ecma_visit", +] + +[[package]] +name = "swc_ecma_utils" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fb99e179988cabd473779a4452ab942bcb777176983ca3cbaf22a8f056a65b0" +dependencies = [ + "indexmap 2.14.0", + "num_cpus", + "once_cell", + "par-core", + "rustc-hash", + "ryu-js", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "swc_ecma_visit" +version = "18.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9611a72a4008d62608547a394e5d72a5245413104db096d95a52368a8cc1d63" +dependencies = [ + "new_debug_unreachable", + "num-bigint", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_visit", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_sourcemap" +version = "9.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de08ef00f816acdd1a58ee8a81c0e1a59eefef2093aefe5611f256fa6b64c4d7" +dependencies = [ + "base64-simd", + "bitvec", + "bytes-str", + "data-encoding", + "debugid", + "if_chain", + "rustc-hash", + "serde", + "serde_json", + "unicode-id-start", + "url", +] + +[[package]] +name = "swc_visit" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +dependencies = [ + "either", + "new_debug_unreachable", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -5183,6 +6077,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5467,7 +6367,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -5521,6 +6421,24 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "text_lines" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd5828de7deaa782e1dd713006ae96b3bee32d3279b79eb67ecf8072c059bcf" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5951,6 +6869,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5974,7 +6902,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1", @@ -6074,6 +7002,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6101,6 +7035,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -6432,7 +7372,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", @@ -7174,6 +8114,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 84f535a1c..dfca7b8e8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,7 +34,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -autoagents = "0.3.7" +autoagents = { git = "https://github.com/liquidos-ai/AutoAgents.git", rev = "57ebeaa4e18989909013ebd58351b3ef6a5586e0", features = ["codeact"] } axum = "0.7" base64 = "0.22" buttplug = "10.0.2" diff --git a/src-tauri/crates/llm/src/lib.rs b/src-tauri/crates/llm/src/lib.rs index 8e0fd421a..85cf82133 100644 --- a/src-tauri/crates/llm/src/lib.rs +++ b/src-tauri/crates/llm/src/lib.rs @@ -284,8 +284,10 @@ fn should_send_top_k(request: &LlmRequest) -> bool { fn provider_error_text(details: &Value) -> Option { [ details.pointer("/error/message").and_then(Value::as_str), + details.pointer("/response/error/message").and_then(Value::as_str), details.get("message").and_then(Value::as_str), details.pointer("/error").and_then(Value::as_str), + details.pointer("/response/error").and_then(Value::as_str), ] .into_iter() .flatten() @@ -530,7 +532,7 @@ async fn complete_openai_compatible_rich(request: LlmRequest) -> AppResult AppResult String { .unwrap_or_default() } -async fn parse_json_response_rich(response: reqwest::Response) -> AppResult { +fn llm_debug_context(request: &LlmRequest, endpoint: &str, body: &Value) -> Value { + let messages = request_messages(request) + .iter() + .map(|message| { + json!({ + "role": message.role, + "contentChars": message.content.chars().count(), + "imageCount": message.images.len(), + "hasToolCallId": message.tool_call_id.is_some(), + "hasToolCalls": message.tool_calls.is_some(), + }) + }) + .collect::>(); + let tool_names = request + .tools + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>(); + json!({ + "provider": request.connection.provider, + "model": request.connection.model, + "endpoint": endpoint, + "messageCount": messages.len(), + "messages": messages, + "toolCount": request.tools.len(), + "toolNames": tool_names, + "parameters": request.parameters, + "bodyKeys": body.as_object().map(|object| object.keys().cloned().collect::>()).unwrap_or_default(), + }) +} + +fn text_excerpt(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn response_debug_details(context: Value, response: Value) -> Value { + json!({ + "context": context, + "response": response, + }) +} + +async fn parse_json_response_rich( + response: reqwest::Response, + debug_context: Value, +) -> AppResult { let status = response.status(); - let json: Value = response - .json() + let body_text = response + .text() .await .map_err(|error| AppError::new("llm_response_error", error.to_string()))?; + let json: Value = serde_json::from_str(&body_text).map_err(|error| { + AppError::with_details( + "llm_response_error", + format!("Provider response was not valid JSON: {error}"), + json!({ + "context": debug_context.clone(), + "responseText": text_excerpt(&body_text, 4096), + }), + ) + })?; if !status.is_success() { - return Err(provider_http_error(status, json)); + return Err(provider_http_error( + status, + response_debug_details(debug_context, json), + )); + } + if json.get("error").is_some() && json.get("choices").is_none() { + return Err(provider_http_error( + status, + response_debug_details(debug_context, json), + )); } let choice = json .get("choices") @@ -1472,7 +1538,7 @@ async fn parse_json_response_rich(response: reqwest::Response) -> AppResult AppResult AppResult { + let input: MariPromptRequest = serde_json::from_value(body) + .map_err(|error| AppError::invalid_input(error.to_string()))?; + let connection_value = resolve_llm_connection_for_request( + state, + &json!({ + "connectionId": input.connection_id, + }), + )?; + let connection = llm_connection_from_value(&connection_value)?; + ensure_connection_supports_native_tools(&connection)?; + + let mut messages = vec![marinara_llm::LlmMessage { + role: "system".to_string(), + content: build_system_prompt(input.persona.as_ref()), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: None, + }]; + messages.push(marinara_llm::LlmMessage { + role: "user".to_string(), + content: build_task_prompt(&input), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: None, + }); + + let content = run_mari_tool_loop(state, connection, messages).await?; + Ok(json!({ + "content": content, + "createdAt": chrono::Utc::now().to_rfc3339(), + "action": read_only_mari_action_contract(), + })) +} + +async fn run_mari_tool_loop( + state: &AppState, connection: marinara_llm::LlmConnection, + mut messages: Vec, +) -> AppResult { + const MAX_TOOL_ROUNDS: usize = 6; + let tools = mari_tool_definitions(); + let mut last_content = String::new(); + + for _ in 0..MAX_TOOL_ROUNDS { + let response = marinara_llm::complete_rich(marinara_llm::LlmRequest { + connection: connection.clone(), + messages: messages.clone(), + parameters: json!({ + "temperature": 0.35, + "maxTokens": 2048, + }), + tools: tools.clone(), + }) + .await + .map_err(|error| { + let debug_error = format_app_error_for_debug(&error); + log::error!("Professor Mari LLM request failed: {debug_error}"); + AppError::new("mari_agent_failed", tool_call_error_message(&debug_error)) + })?; + + last_content = response.content.trim().to_string(); + if response.tool_calls.is_empty() { + return Ok(if last_content.is_empty() { + "I couldn't produce a response from the selected model.".to_string() + } else { + last_content + }); + } + + messages.push(marinara_llm::LlmMessage { + role: "assistant".to_string(), + content: response.content, + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: Some(Value::Array( + response + .tool_calls + .iter() + .map(normalize_tool_call_for_chat_history) + .collect(), + )), + }); + + for call in response.tool_calls { + let result = execute_mari_tool_call(state, &call); + messages.push(marinara_llm::LlmMessage { + role: "tool".to_string(), + content: result.to_string(), + name: None, + images: Vec::new(), + tool_call_id: tool_call_id(&call), + tool_calls: None, + }); + } + } + + let response = marinara_llm::complete_rich(marinara_llm::LlmRequest { + connection, + messages, + parameters: json!({ + "temperature": 0.35, + "maxTokens": 2048, + }), + tools: Vec::new(), + }) + .await + .map_err(|error| AppError::new("mari_agent_failed", format_app_error_for_debug(&error)))?; + + let final_content = response.content.trim(); + if final_content.is_empty() { + Ok(last_content) + } else { + Ok(final_content.to_string()) + } } -#[derive(Debug)] -struct MarinaraChatResponse { - content: String, - tool_calls: Vec, +fn execute_mari_tool_call(state: &AppState, call: &Value) -> Value { + let name = tool_call_name(call); + let args = tool_call_arguments(call); + let result = match name.as_str() { + "bash" => { + let command = args.get("command").and_then(Value::as_str).unwrap_or(""); + Ok(run_virtual_bash(state, command)) + } + "ls" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); + mari_fs::ls(state, path) + } + "read" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); + let offset = args.get("offset").and_then(Value::as_u64).map(|value| value as usize); + let limit = args.get("limit").and_then(Value::as_u64).map(|value| value as usize); + mari_fs::read(state, path, offset, limit) + } + "edit" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); + let edits = args + .get("edits") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(( + item.get("oldText")?.as_str()?.to_string(), + item.get("newText")?.as_str()?.to_string(), + )) + }) + .collect::>() + }) + .unwrap_or_default(); + mari_fs::edit(state, path, &edits) + } + "write" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); + let content = args.get("content").and_then(Value::as_str).unwrap_or(""); + mari_fs::write(state, path, content) + } + _ => Err(AppError::invalid_input(format!("Unknown Professor Mari tool: {name}"))), + }; + + match result { + Ok(value) => json!({ "ok": true, "result": value }), + Err(error) => json!({ "ok": false, "error": error.to_string() }), + } } -impl fmt::Display for MarinaraChatResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.content) +fn mari_tool_definitions() -> Vec { + vec![ + json!({ + "name": "bash", + "description": "Run a safe virtual shell command in Professor Mari's Marinara workspace. This is not the host shell. Supports ls, cat/read, head, tail, grep, find, write, rm, cp, and mv with simple pipes over virtual workspace text only.", + "parameters": { + "type": "object", + "properties": { + "command": { "type": "string", "description": "Safe virtual shell command, e.g. ls /library, cat /library/characters/index.json, find /library -name '*makima*', grep -R Makima /library, write /library/personas/new.json {\"name\":\"New\"}." } + }, + "required": ["command"] + } + }), + json!({ + "name": "edit", + "description": "Edit a file in Professor Mari's virtual Marinara workspace using exact text replacements. Each oldText must match exactly once in the file content returned by read.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Virtual file path to edit. Use read first, then copy exact oldText from the returned content." }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { "type": "string", "description": "Exact text to replace. Must match once." }, + "newText": { "type": "string", "description": "Replacement text." } + }, + "required": ["oldText", "newText"] + } + } + }, + "required": ["path", "edits"] + } + }), + json!({ + "name": "write", + "description": "Write a complete JSON file in Professor Mari's virtual Marinara workspace. Existing paths update storage records; new valid library paths create records and auto-assign them to the parent folder/preset/lorebook when applicable.", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Virtual file path to write." }, + "content": { "type": "string", "description": "Complete JSON content to write." } + }, + "required": ["path", "content"] + } + }), + ] +} + +fn run_virtual_bash(state: &AppState, command: &str) -> Value { + match run_virtual_bash_result(state, command) { + Ok(stdout) => json!({ "stdout": stdout, "stderr": "", "exitCode": 0 }), + Err(error) => json!({ "stdout": "", "stderr": error.to_string(), "exitCode": 1 }), } } -impl ChatResponse for MarinaraChatResponse { - fn text(&self) -> Option { - Some(self.content.clone()) +fn run_virtual_bash_result(state: &AppState, command: &str) -> AppResult { + reject_unsafe_shell(command)?; + let mut input: Option = None; + for segment in split_pipes(command)? { + input = Some(run_virtual_bash_segment(state, segment.trim(), input)?); } + Ok(input.unwrap_or_default()) +} - fn tool_calls(&self) -> Option> { - Some(self.tool_calls.clone()) +fn run_virtual_bash_segment(state: &AppState, segment: &str, input: Option) -> AppResult { + let tokens = shell_words(segment)?; + let Some(cmd) = tokens.first().map(String::as_str) else { + return Ok(input.unwrap_or_default()); + }; + match cmd { + "ls" => { + let path = tokens.get(1).map(String::as_str).unwrap_or("/"); + let value = mari_fs::ls(state, path)?; + Ok(format_ls_output(&value)) + } + "cat" | "read" => { + let path = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("cat requires a path"))?; + read_file_text(state, path) + } + "head" => { + let (n, path) = parse_n_and_path(&tokens, 10); + let text = match path { Some(path) => read_file_text(state, path)?, None => input.unwrap_or_default() }; + Ok(text.lines().take(n).collect::>().join("\n")) + } + "tail" => { + let (n, path) = parse_n_and_path(&tokens, 10); + let text = match path { Some(path) => read_file_text(state, path)?, None => input.unwrap_or_default() }; + let lines = text.lines().collect::>(); + let start = lines.len().saturating_sub(n); + Ok(lines[start..].join("\n")) + } + "grep" => run_grep(state, &tokens, input), + "find" => run_find(state, &tokens), + "rm" => { + let path = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("rm requires a path"))?; + let value = mari_fs::rm(state, path)?; + serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) + } + "cp" => { + let from = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("cp requires a source path"))?; + let to = tokens.get(2).map(String::as_str).ok_or_else(|| AppError::invalid_input("cp requires a destination path"))?; + let content = read_file_text(state, from)?; + let value = mari_fs::write(state, to, &content)?; + serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) + } + "mv" => { + let from = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("mv requires a source path"))?; + let to = tokens.get(2).map(String::as_str).ok_or_else(|| AppError::invalid_input("mv requires a destination path"))?; + let content = read_file_text(state, from)?; + let written = mari_fs::write(state, to, &content)?; + let removed = mari_fs::rm(state, from)?; + serde_json::to_string_pretty(&json!({ "written": written, "removed": removed })).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) + } + "write" => run_write_command(state, segment), + other => Err(AppError::invalid_input(format!("Unsupported virtual bash command: {other}"))), } } -#[async_trait] -impl ChatProvider for MarinaraLlmProvider { - async fn chat_with_tools( - &self, - messages: &[ChatMessage], - tools: Option<&[Tool]>, - _json_schema: Option, - ) -> Result, LLMError> { - let request = marinara_llm::LlmRequest { - connection: self.connection.clone(), - messages: messages - .iter() - .map(autoagents_message_to_marinara) - .collect(), - parameters: mari_request_parameters(messages, tools.unwrap_or_default()), - tools: tools - .unwrap_or_default() - .iter() - .map(|tool| serde_json::to_value(&tool.function).unwrap_or_else(|_| json!({}))) - .collect(), - }; - let response = marinara_llm::complete_rich(request) - .await - .map_err(|error| LLMError::ProviderError(error.to_string()))?; - Ok(Box::new(MarinaraChatResponse { - content: response.content, - tool_calls: response - .tool_calls - .into_iter() - .filter_map(marinara_tool_call_to_autoagents) - .collect(), - })) +fn read_file_text(state: &AppState, path: &str) -> AppResult { + let value = mari_fs::read(state, path, Some(1), Some(240))?; + Ok(value.get("content").and_then(Value::as_str).unwrap_or("").to_string()) +} + +fn run_write_command(state: &AppState, segment: &str) -> AppResult { + let rest = segment.trim_start_matches("write").trim(); + let mut split = rest.splitn(2, char::is_whitespace); + let path = split.next().filter(|value| !value.trim().is_empty()).ok_or_else(|| AppError::invalid_input("write requires a path"))?; + let content = split.next().unwrap_or("").trim(); + if content.is_empty() { + return Err(AppError::invalid_input("write requires JSON content after the path")); } + let value = mari_fs::write(state, path, content)?; + serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) } -fn mari_request_parameters(messages: &[ChatMessage], tools: &[Tool]) -> Value { - let mut parameters = json!({ - "temperature": 0.4, - "maxTokens": 2048, - }); - let has_tool_result = messages - .iter() - .any(|message| matches!(message.message_type, MessageType::ToolResult(_))); - let latest_user = messages - .iter() - .rev() - .find(|message| matches!(message.role, ChatRole::User)) - .map(|message| message.content.as_str()) - .unwrap_or_default(); - if !tools.is_empty() && !has_tool_result && looks_like_library_question(latest_user) { - parameters["toolChoice"] = json!({ - "type": "function", - "function": { "name": "read_marinara_library" } - }); +fn run_grep(state: &AppState, tokens: &[String], input: Option) -> AppResult { + let mut case_sensitive = true; + let mut recursive = false; + let mut args = Vec::new(); + for token in tokens.iter().skip(1) { + match token.as_str() { + "-i" => case_sensitive = false, + "-R" | "-r" => recursive = true, + other => args.push(other.to_string()), + } } - parameters -} - -#[async_trait] -impl CompletionProvider for MarinaraLlmProvider { - async fn complete( - &self, - request: &CompletionRequest, - _json_schema: Option, - ) -> Result { - let response = self - .chat( - &[ChatMessage { - role: ChatRole::User, - message_type: MessageType::Text, - content: request.prompt.clone(), - }], - None, - ) - .await?; - Ok(CompletionResponse { - text: response.text().unwrap_or_default(), - }) + let pattern = args.first().ok_or_else(|| AppError::invalid_input("grep requires a pattern"))?; + let needle = if case_sensitive { pattern.clone() } else { pattern.to_ascii_lowercase() }; + if let Some(text) = input { + return Ok(text.lines().filter(|line| { + let haystack = if case_sensitive { (*line).to_string() } else { line.to_ascii_lowercase() }; + haystack.contains(&needle) + }).collect::>().join("\n")); } + let path = args.get(1).map(String::as_str).unwrap_or("/"); + let files = if recursive { collect_paths(state, path, true, None, None)? } else { vec![path.to_string()] }; + let mut out = Vec::new(); + for file in files { + let Ok(text) = read_file_text(state, &file) else { continue; }; + for (index, line) in text.lines().enumerate() { + let haystack = if case_sensitive { line.to_string() } else { line.to_ascii_lowercase() }; + if haystack.contains(&needle) { + out.push(format!("{file}:{}:{line}", index + 1)); + } + } + } + Ok(out.join("\n")) } -#[async_trait] -impl EmbeddingProvider for MarinaraLlmProvider { - async fn embed(&self, _input: Vec) -> Result>, LLMError> { - Err(LLMError::ProviderError( - "Professor Mari does not expose embeddings in v1".to_string(), - )) +fn run_find(state: &AppState, tokens: &[String]) -> AppResult { + let root = tokens.get(1).map(String::as_str).unwrap_or("/"); + let mut name_pattern: Option = None; + let mut entry_type: Option = None; + let mut max_depth: Option = None; + let mut index = 2; + while index < tokens.len() { + match tokens[index].as_str() { + "-name" => { name_pattern = tokens.get(index + 1).cloned(); index += 2; } + "-type" => { entry_type = tokens.get(index + 1).cloned(); index += 2; } + "-maxdepth" => { max_depth = tokens.get(index + 1).and_then(|v| v.parse().ok()); index += 2; } + _ => index += 1, + } } + Ok(collect_paths(state, root, entry_type.as_deref() != Some("d"), name_pattern.as_deref(), max_depth)?.join("\n")) } -#[async_trait] -impl ModelsProvider for MarinaraLlmProvider { - async fn list_models( - &self, - _request: Option<&ModelListRequest>, - ) -> Result, LLMError> { - Err(LLMError::ProviderError( - "Professor Mari model listing is owned by Marinara connections".to_string(), - )) +fn collect_paths(state: &AppState, root: &str, files: bool, pattern: Option<&str>, max_depth: Option) -> AppResult> { + fn walk(state: &AppState, path: &str, depth: usize, files: bool, pattern: Option<&str>, max_depth: Option, out: &mut Vec) -> AppResult<()> { + if max_depth.is_some_and(|max| depth > max) { return Ok(()); } + let listing = mari_fs::ls(state, path)?; + for entry in listing.get("entries").and_then(Value::as_array).into_iter().flatten() { + let entry_path = entry.get("path").and_then(Value::as_str).unwrap_or(""); + let entry_name = entry.get("name").and_then(Value::as_str).unwrap_or(""); + let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("file"); + let matches = pattern.map(|pat| wildcard_match(pat, entry_name) || wildcard_match(pat, entry_path)).unwrap_or(true); + if ((files && entry_type == "file") || (!files && entry_type == "directory")) && matches { + out.push(entry_path.to_string()); + } + if entry_type == "directory" { + let _ = walk(state, entry_path, depth + 1, files, pattern, max_depth, out); + } + } + Ok(()) } + let mut out = Vec::new(); + walk(state, root, 0, files, pattern, max_depth, &mut out)?; + Ok(out) } -impl LLMProvider for MarinaraLlmProvider {} - -#[derive(Serialize, Deserialize, ToolInput, Debug)] -struct ReadMarinaraLibraryArgs {} +fn parse_n_and_path(tokens: &[String], default_n: usize) -> (usize, Option<&str>) { + if tokens.get(1).map(String::as_str) == Some("-n") { + (tokens.get(2).and_then(|v| v.parse().ok()).unwrap_or(default_n), tokens.get(3).map(String::as_str)) + } else { + (default_n, tokens.get(1).map(String::as_str)) + } +} -#[tool( - name = "read_marinara_library", - description = "Read Professor Mari's typed, read-only creative library snapshot: characters, personas, lorebooks with entries, prompt presets, prompt sections, prompt groups, prompt variables, and character/persona groups. This tool never returns chats, messages, memories, integrations, API keys, or connection secrets.", - input = ReadMarinaraLibraryArgs, -)] -struct ReadMarinaraLibraryTool { - state: AppState, +fn format_ls_output(value: &Value) -> String { + value.get("entries").and_then(Value::as_array).map(|entries| { + entries.iter().filter_map(|entry| entry.get("path").and_then(Value::as_str)).collect::>().join("\n") + }).unwrap_or_else(|| value.to_string()) } -#[async_trait] -impl ToolRuntime for ReadMarinaraLibraryTool { - async fn execute(&self, _args: Value) -> Result { - creative_library_snapshot(&self.state).map_err(|error| { - ToolCallError::RuntimeError(Box::new(AppError::new( - "mari_library_read_failed", - error.to_string(), - ))) - }) +fn split_pipes(command: &str) -> AppResult> { + let mut parts = Vec::new(); + let mut start = 0; + let mut quote: Option = None; + for (index, ch) in command.char_indices() { + match ch { + '\'' | '"' if quote == Some(ch) => quote = None, + '\'' | '"' if quote.is_none() => quote = Some(ch), + '|' if quote.is_none() => { parts.push(command[start..index].trim()); start = index + 1; } + _ => {} + } } + if quote.is_some() { return Err(AppError::invalid_input("Unclosed quote in virtual bash command")); } + parts.push(command[start..].trim()); + Ok(parts.into_iter().filter(|part| !part.is_empty()).collect()) } -#[agent( - name = "professor_mari", - description = "You are Professor Mari, Marinara's standalone assistant. You answer the user's question clearly and can inspect Marinara's creative library by calling read_marinara_library. You must not claim to edit data, run shell commands, or access chats/messages/memories. If the user asks what library data is available, call the tool.", - tools = [ReadMarinaraLibraryTool { state: self.state.clone() }], -)] -#[derive(Clone, AgentHooks)] -struct ProfessorMariAgent { - state: AppState, +fn shell_words(segment: &str) -> AppResult> { + let mut words = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + for ch in segment.chars() { + match ch { + '\'' | '"' if quote == Some(ch) => quote = None, + '\'' | '"' if quote.is_none() => quote = Some(ch), + ch if ch.is_whitespace() && quote.is_none() => { + if !current.is_empty() { words.push(std::mem::take(&mut current)); } + } + _ => current.push(ch), + } + } + if quote.is_some() { return Err(AppError::invalid_input("Unclosed quote in virtual bash command")); } + if !current.is_empty() { words.push(current); } + Ok(words) } -pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppResult { - let input: MariPromptRequest = serde_json::from_value(body.clone()) - .map_err(|error| AppError::invalid_input(error.to_string()))?; - let connection_value = resolve_llm_connection_for_request( - state, - &json!({ - "connectionId": input.connection_id, - }), - )?; - let connection = llm_connection_from_value(&connection_value)?; - ensure_connection_supports_native_tools(&connection)?; - let system_prompt = build_system_prompt(input.persona.as_ref()); - let task_prompt = build_task_prompt(&input); - let provider: Arc = Arc::new(MarinaraLlmProvider { connection }); - let memory = Box::new(SlidingWindowMemory::new(12)); - let agent = ReActAgent::with_max_turns( - ProfessorMariAgent { - state: state.clone(), - }, - 4, - ); - let agent_handle = AgentBuilder::<_, DirectAgent>::new(agent) - .llm(provider) - .memory(memory) - .build() - .await - .map_err(|error| AppError::new("mari_agent_create_failed", error.to_string()))?; - let task = Task::new(task_prompt).with_system_prompt(system_prompt); - let response = agent_handle.agent.run(task).await.map_err(|error| { - AppError::new( - "mari_agent_failed", - tool_call_error_message(&error.to_string()), - ) - })?; +fn reject_unsafe_shell(command: &str) -> AppResult<()> { + let blocked = [";", "&&", "||", "`", "$(", ">", "<", " sudo ", " curl ", " wget ", " powershell", " cmd", " python", " node", " chmod", " chown"]; + let padded = format!(" {} ", command.to_ascii_lowercase()); + if let Some(token) = blocked.iter().find(|token| padded.contains(**token)) { + return Err(AppError::invalid_input(format!("Unsupported or unsafe virtual bash syntax: {token}"))); + } + Ok(()) +} - Ok(json!({ - "content": response.to_string(), - "createdAt": chrono::Utc::now().to_rfc3339(), - "action": read_only_mari_action_contract(), - })) +fn wildcard_match(pattern: &str, value: &str) -> bool { + let pattern = pattern.to_ascii_lowercase(); + let value = value.to_ascii_lowercase(); + if pattern == "*" { return true; } + let parts = pattern.split('*').collect::>(); + if parts.len() == 1 { return value.contains(&pattern); } + let mut remainder = value.as_str(); + for (index, part) in parts.iter().filter(|part| !part.is_empty()).enumerate() { + if let Some(pos) = remainder.find(part) { + if index == 0 && !pattern.starts_with('*') && pos != 0 { return false; } + remainder = &remainder[pos + part.len()..]; + } else { return false; } + } + pattern.ends_with('*') || parts.last().is_some_and(|last| remainder.is_empty() || last.is_empty()) } -fn read_only_mari_action_contract() -> Value { +fn normalize_tool_call_for_chat_history(call: &Value) -> Value { + let name = tool_call_name(call); + let arguments = call + .get("function") + .and_then(|function| function.get("arguments")) + .or_else(|| call.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}") + .to_string(); json!({ - "type": "none", - "capability": "read_only", - "reason": "Professor Mari v1 can inspect the creative library but cannot create or edit records.", + "id": tool_call_id(call).unwrap_or_else(|| "mari_tool_call".to_string()), + "type": "function", + "function": { + "name": name, + "arguments": arguments, + } }) } -fn autoagents_message_to_marinara(message: &ChatMessage) -> marinara_llm::LlmMessage { - let first_tool_result = match &message.message_type { - MessageType::ToolResult(calls) => calls.first(), - _ => None, - }; - let role = match message.role { - ChatRole::System => "system", - ChatRole::Assistant => "assistant", - ChatRole::Tool => "tool", - ChatRole::User => "user", - } - .to_string(); - let tool_calls = match &message.message_type { - MessageType::ToolUse(calls) => Some(json!(calls)), - _ => None, - }; - marinara_llm::LlmMessage { - role, - content: first_tool_result - .map(|call| call.function.arguments.clone()) - .unwrap_or_else(|| message.content.clone()), - name: None, - images: Vec::new(), - tool_call_id: first_tool_result.map(|call| call.id.clone()), - tool_calls, +fn tool_call_name(call: &Value) -> String { + call.get("function") + .and_then(|function| function.get("name")) + .or_else(|| call.get("name")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +fn tool_call_arguments(call: &Value) -> Value { + let raw = call + .get("function") + .and_then(|function| function.get("arguments")) + .or_else(|| call.get("arguments")); + match raw { + Some(Value::String(text)) => serde_json::from_str(text).unwrap_or_else(|_| json!({})), + Some(Value::Object(_)) => raw.cloned().unwrap_or_else(|| json!({})), + _ => json!({}), } } -fn marinara_tool_call_to_autoagents(value: Value) -> Option { - let function = value.get("function").unwrap_or(&value); - let name = function - .get("name") - .or_else(|| value.get("name"))? - .as_str()? - .to_string(); - let arguments = function - .get("arguments") - .or_else(|| value.get("arguments")) +fn tool_call_id(call: &Value) -> Option { + call.get("id") .and_then(Value::as_str) - .unwrap_or("{}") - .to_string(); - Some(ToolCall { - id: value - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .unwrap_or("mari_tool_call") - .to_string(), - call_type: value - .get("type") - .and_then(Value::as_str) - .unwrap_or("function") - .to_string(), - function: FunctionCall { name, arguments }, + .filter(|id| !id.trim().is_empty()) + .or_else(|| call.get("call_id").and_then(Value::as_str)) + .map(str::to_string) +} + +fn read_only_mari_action_contract() -> Value { + json!({ + "type": "none", + "capability": "read_only", + "reason": "Professor Mari v1 can inspect the creative library but cannot create or edit records.", }) } fn build_system_prompt(persona: Option<&MariPersonaContext>) -> String { let mut parts = vec![ "You are Professor Mari, a standalone assistant inside Marinara Engine.".to_string(), - "You can chat with the user and read the creative library through read_marinara_library.".to_string(), - "The read-only library tool returns typed JSON objects. Do not invent data if the tool is needed.".to_string(), - "You cannot mutate records, run shell commands, inspect private chats, or access secrets in v1.".to_string(), + "You have a virtual workspace with two tools: bash and edit.".to_string(), + "Use bash for safe virtual shell commands: ls, cat/read, head, tail, grep, find, write, rm, cp, and mv. Use edit for exact structured oldText/newText replacements. Treat paths as virtual Marinara workspace paths, not host filesystem paths.".to_string(), + "The workspace exposes creative-library records only: characters, character groups, personas, persona groups, lorebooks with nested entries, and prompt presets with nested sections/groups/variables.".to_string(), + "You may edit and write records in this workspace. edit uses exact oldText/newText replacements against read output. write writes complete JSON content to a writable virtual file and stores it in Marinara.".to_string(), + "You cannot delete records, run shell commands, access network resources, access chats/messages/memories, or view secrets/connections/API keys.".to_string(), + "If the user asks about library data, inspect it with ls/read before answering. Do not invent data. Answer plainly after using any needed tools.".to_string(), ]; if let Some(persona) = persona { let persona_text = [ @@ -421,28 +636,7 @@ fn build_task_prompt(input: &MariPromptRequest) -> String { sections.join("\n\n") } -fn looks_like_library_question(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - [ - "character", - "characters", - "persona", - "personas", - "lorebook", - "lorebooks", - "prompt", - "preset", - "presets", - "library", - "what do i have", - ] - .iter() - .any(|needle| lower.contains(needle)) -} - -fn ensure_connection_supports_native_tools( - connection: &marinara_llm::LlmConnection, -) -> AppResult<()> { +fn ensure_connection_supports_native_tools(connection: &marinara_llm::LlmConnection) -> AppResult<()> { match connection.provider.as_str() { "openai" | "openai_chatgpt" | "openrouter" | "custom" | "xai" | "mistral" | "cohere" | "nanogpt" => Ok(()), provider => Err(AppError::invalid_input(format!( @@ -458,11 +652,13 @@ fn tool_call_error_message(message: &str) -> String { message.to_string() } -fn creative_library_snapshot(state: &AppState) -> AppResult { - let mut snapshot = serde_json::Map::new(); - for (key, entity) in CREATIVE_LIBRARY_ENTITIES { - let rows = state.storage.list(entity)?; - snapshot.insert((*key).to_string(), Value::Array(rows)); +fn format_app_error_for_debug(error: &AppError) -> String { + let mut message = error.to_string(); + if let Some(details) = &error.details { + let details = serde_json::to_string_pretty(details) + .unwrap_or_else(|serialize_error| format!("Could not serialize error details: {serialize_error}")); + message.push_str("\nProvider debug details:\n"); + message.push_str(&details.chars().take(12_000).collect::()); } - Ok(Value::Object(snapshot)) + message } diff --git a/src-tauri/src/commands/storage/mari_fs.rs b/src-tauri/src/commands/storage/mari_fs.rs new file mode 100644 index 000000000..a23affae7 --- /dev/null +++ b/src-tauri/src/commands/storage/mari_fs.rs @@ -0,0 +1,744 @@ +use crate::state::AppState; +use marinara_core::{AppError, AppResult}; +use serde_json::{json, Map, Value}; + +const MAX_READ_LINES: usize = 240; +const MAX_READ_BYTES: usize = 48 * 1024; + +const ROOT_MOUNTS: &[MariMount] = &[ + MariMount { dir: "characters", collection: "characters", label: "Characters", prefix: "character" }, + MariMount { dir: "character-groups", collection: "character-groups", label: "Character groups", prefix: "character-group" }, + MariMount { dir: "personas", collection: "personas", label: "Personas", prefix: "persona" }, + MariMount { dir: "persona-groups", collection: "persona-groups", label: "Persona groups", prefix: "persona-group" }, + MariMount { dir: "lorebooks", collection: "lorebooks", label: "Lorebooks", prefix: "lorebook" }, + MariMount { dir: "prompts", collection: "prompts", label: "Prompt presets", prefix: "prompt" }, +]; + +const PROMPT_CHILD_MOUNTS: &[MariMount] = &[ + MariMount { dir: "sections", collection: "prompt-sections", label: "Prompt sections", prefix: "section" }, + MariMount { dir: "groups", collection: "prompt-groups", label: "Prompt groups", prefix: "group" }, + MariMount { dir: "variables", collection: "prompt-variables", label: "Prompt variables", prefix: "variable" }, +]; + +const LOREBOOK_ENTRY_MOUNT: MariMount = MariMount { + dir: "entries", + collection: "lorebook-entries", + label: "Lorebook entries", + prefix: "entry", +}; + +struct MariMount { + dir: &'static str, + collection: &'static str, + label: &'static str, + prefix: &'static str, +} + +#[derive(Clone)] +struct MariRecordRef { + ordinal: usize, + id: String, + label: String, + path: String, +} + +pub(crate) fn ls(state: &AppState, path: &str) -> AppResult { + let normalized = normalize_path(path)?; + let parts = parts(&normalized); + match parts.as_slice() { + [] => Ok(json!({ + "path": "/", + "entries": [ + { "name": "help.md", "type": "file", "path": "/help.md" }, + { "name": "library", "type": "directory", "path": "/library" }, + { "name": "schema", "type": "directory", "path": "/schema" } + ] + })), + ["library"] => Ok(json!({ + "path": "/library", + "entries": ROOT_MOUNTS.iter().map(|mount| json!({ + "name": mount.dir, + "type": "directory", + "path": format!("/library/{}", mount.dir), + "label": mount.label, + "collection": mount.collection, + })).collect::>() + })), + ["library", "prompts"] => ls_parent_collection(state, root_mount("prompts")?, true), + ["library", "prompts", prompt_dir] => ls_prompt_dir(state, prompt_dir), + ["library", "prompts", prompt_dir, child_dir] => ls_prompt_child_dir(state, prompt_dir, child_dir), + ["library", "lorebooks"] => ls_parent_collection(state, root_mount("lorebooks")?, true), + ["library", "lorebooks", lorebook_dir] => ls_lorebook_dir(state, lorebook_dir), + ["library", "lorebooks", lorebook_dir, "entries"] => ls_lorebook_entries_dir(state, lorebook_dir), + ["library", dir] => { + let mount = root_mount(dir)?; + ls_flat_collection(state, mount) + } + ["schema"] => Ok(json!({ + "path": "/schema", + "entries": schema_entries(), + })), + _ => Err(AppError::not_found(format!("No such directory in Professor Mari workspace: {normalized}"))), + } +} + +pub(crate) fn read(state: &AppState, path: &str, offset: Option, limit: Option) -> AppResult { + let normalized = normalize_path(path)?; + let content = read_content(state, &normalized)?; + let total_lines = content.lines().count().max(1); + let offset = offset.unwrap_or(1).max(1); + let limit = limit.unwrap_or(MAX_READ_LINES).min(MAX_READ_LINES).max(1); + let mut selected = content.lines().skip(offset.saturating_sub(1)).take(limit).collect::>().join("\n"); + let mut truncated = offset.saturating_sub(1) + limit < total_lines; + if selected.len() > MAX_READ_BYTES { + selected.truncate(MAX_READ_BYTES); + truncated = true; + } + Ok(json!({ + "path": normalized, + "offset": offset, + "limit": limit, + "totalLines": total_lines, + "truncated": truncated, + "content": selected, + })) +} + +pub(crate) fn write(state: &AppState, path: &str, content: &str) -> AppResult { + let normalized = normalize_path(path)?; + let parsed = serde_json::from_str::(content) + .map_err(|error| AppError::invalid_input(format!("write content must be valid JSON for {normalized}: {error}")))?; + write_json_content(state, &normalized, parsed) +} + +pub(crate) fn edit(state: &AppState, path: &str, edits: &[(String, String)]) -> AppResult { + if edits.is_empty() { + return Err(AppError::invalid_input("edit requires at least one replacement")); + } + let normalized = normalize_path(path)?; + let mut content = read_content(state, &normalized)?; + for (old_text, new_text) in edits { + if old_text.is_empty() { + return Err(AppError::invalid_input("edit oldText must not be empty")); + } + let count = content.matches(old_text).count(); + if count != 1 { + return Err(AppError::invalid_input(format!( + "edit oldText must match exactly once in {normalized}; matched {count} times" + ))); + } + content = content.replacen(old_text, new_text, 1); + } + write(state, &normalized, &content) +} + +pub(crate) fn rm(state: &AppState, path: &str) -> AppResult { + let normalized = normalize_path(path)?; + let parts = parts(&normalized); + let (collection, id) = match parts.as_slice() { + ["library", "prompts", prompt_dir] | ["library", "prompts", prompt_dir, "preset.json"] => { + let prompt = prompt_ref(state, prompt_dir)?; + ("prompts", prompt.id) + } + ["library", "prompts", prompt_dir, child_dir, filename] => { + let prompt = prompt_ref(state, prompt_dir)?; + let mount = prompt_child_mount(child_dir)?; + let id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename)?; + (mount.collection, id) + } + ["library", "lorebooks", lorebook_dir] | ["library", "lorebooks", lorebook_dir, "book.json"] => { + let lorebook = lorebook_ref(state, lorebook_dir)?; + ("lorebooks", lorebook.id) + } + ["library", "lorebooks", lorebook_dir, "entries", filename] => { + let lorebook = lorebook_ref(state, lorebook_dir)?; + let id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename)?; + ("lorebook-entries", id) + } + ["library", dir, filename] => { + let mount = root_mount(dir)?; + if mount.collection == "prompts" || mount.collection == "lorebooks" { + return Err(AppError::invalid_input("Delete prompt and lorebook parents by their directory path or parent file path")); + } + let id = resolve_record_id_from_file_name(state, mount, None, filename)?; + (mount.collection, id) + } + _ => return Err(AppError::invalid_input(format!("Path is not deletable in Professor Mari workspace: {normalized}"))), + }; + let deleted = state.storage.delete(collection, &id)?; + Ok(json!({ "path": normalized, "collection": collection, "id": id, "deleted": deleted })) +} + +fn write_json_content(state: &AppState, normalized: &str, content: Value) -> AppResult { + let parts = parts(normalized); + match parts.as_slice() { + ["library", "prompts", prompt_dir, "preset.json"] => { + let existing = prompt_ref(state, prompt_dir).ok(); + let id = existing.as_ref().map(|record| record.id.clone()); + let record = upsert_record(state, "prompts", id, merge_for_parent_file(state, "prompts", existing.as_ref().map(|record| record.id.as_str()), content, &["sectionOrder", "groupOrder", "variableGroups", "variableValues", "sections", "groups", "variables"])?)?; + Ok(json!({ "path": prompt_record_path(state, &record, "preset.json")?, "record": strip_prompt_children(record) })) + } + ["library", "prompts", prompt_dir, child_dir, filename] => { + let prompt = prompt_ref(state, prompt_dir)?; + let mount = prompt_child_mount(child_dir)?; + let existing_id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename).ok(); + let mut object = ensure_json_object(content)?; + object.insert("presetId".to_string(), Value::String(prompt.id.clone())); + let record = upsert_record(state, mount.collection, existing_id, Value::Object(object))?; + Ok(json!({ "path": child_record_path(state, &prompt, mount, &record)?, "record": record })) + } + ["library", "lorebooks", lorebook_dir, "book.json"] => { + let existing = lorebook_ref(state, lorebook_dir).ok(); + let id = existing.as_ref().map(|record| record.id.clone()); + let record = upsert_record(state, "lorebooks", id, merge_for_parent_file(state, "lorebooks", existing.as_ref().map(|record| record.id.as_str()), content, &["entries", "folders"])?)?; + Ok(json!({ "path": lorebook_record_path(state, &record, "book.json")?, "record": strip_lorebook_children(record) })) + } + ["library", "lorebooks", lorebook_dir, "entries", filename] => { + let lorebook = lorebook_ref(state, lorebook_dir)?; + let existing_id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename).ok(); + let mut object = ensure_json_object(content)?; + object.insert("lorebookId".to_string(), Value::String(lorebook.id.clone())); + let record = upsert_record(state, "lorebook-entries", existing_id, Value::Object(object))?; + Ok(json!({ "path": child_record_path(state, &lorebook, &LOREBOOK_ENTRY_MOUNT, &record)?, "record": record })) + } + ["library", dir, filename] => { + let mount = root_mount(dir)?; + if mount.collection == "prompts" || mount.collection == "lorebooks" { + return Err(AppError::invalid_input(format!("Write {} records through their nested parent file", mount.label))); + } + let existing_id = resolve_record_id_from_file_name(state, mount, None, filename).ok(); + let record = upsert_record(state, mount.collection, existing_id, content)?; + Ok(json!({ "path": flat_record_path(state, mount, &record)?, "record": record })) + } + _ => Err(AppError::invalid_input(format!("Path is not writable in Professor Mari workspace: {normalized}"))), + } +} +fn read_content(state: &AppState, normalized: &str) -> AppResult { + let parts = parts(normalized); + match parts.as_slice() { + ["help.md"] => Ok(help_text()), + ["library", "prompts", "index.json"] => prompt_index_content(state), + ["library", "prompts", prompt_dir, "preset.json"] => { + let prompt = read_prompt_record(state, prompt_dir)?; + pretty(strip_prompt_children(prompt)) + } + ["library", "prompts", prompt_dir, child_dir, "index.json"] => prompt_child_index_content(state, prompt_dir, child_dir), + ["library", "prompts", prompt_dir, child_dir, filename] => read_prompt_child_record(state, prompt_dir, child_dir, filename), + ["library", "lorebooks", "index.json"] => lorebook_index_content(state), + ["library", "lorebooks", lorebook_dir, "book.json"] => { + let lorebook = read_lorebook_record(state, lorebook_dir)?; + pretty(strip_lorebook_children(lorebook)) + } + ["library", "lorebooks", lorebook_dir, "entries", "index.json"] => lorebook_entries_index_content(state, lorebook_dir), + ["library", "lorebooks", lorebook_dir, "entries", filename] => read_lorebook_entry_record(state, lorebook_dir, filename), + ["library", dir, "index.json"] => { + let mount = root_mount(dir)?; + flat_index_content(state, mount) + } + ["library", dir, filename] => { + let mount = root_mount(dir)?; + let id = resolve_record_id_from_file_name(state, mount, None, filename)?; + let row = state.storage.get(mount.collection, &id)?.ok_or_else(|| AppError::not_found(format!("No record at {normalized}")))?; + pretty(row) + } + ["schema", filename] => schema_content(filename), + _ => Err(AppError::not_found(format!("No such file in Professor Mari workspace: {normalized}"))), + } +} + +fn ls_flat_collection(state: &AppState, mount: &MariMount) -> AppResult { + let records = record_refs(state, mount, None)?; + let mut entries = vec![json!({ + "name": "index.json", + "type": "file", + "path": format!("/library/{}/index.json", mount.dir), + })]; + entries.extend(records.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "name": name, + "type": "file", + "path": record.path, + "entity": mount.collection, + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + }) + })); + Ok(json!({ "path": format!("/library/{}", mount.dir), "entries": entries })) +} + +fn ls_parent_collection(state: &AppState, mount: &MariMount, as_dirs: bool) -> AppResult { + let records = record_refs(state, mount, None)?; + let mut entries = vec![json!({ + "name": "index.json", + "type": "file", + "path": format!("/library/{}/index.json", mount.dir), + })]; + entries.extend(records.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "name": name, + "type": if as_dirs { "directory" } else { "file" }, + "path": record.path, + "entity": mount.collection, + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + }) + })); + Ok(json!({ "path": format!("/library/{}", mount.dir), "entries": entries })) +} + +fn ls_prompt_dir(state: &AppState, prompt_dir: &str) -> AppResult { + let prompt = prompt_ref(state, prompt_dir)?; + Ok(json!({ + "path": prompt.path, + "label": prompt.label, + "entries": [ + { "name": "preset.json", "type": "file", "path": format!("{}/preset.json", prompt.path) }, + { "name": "sections", "type": "directory", "path": format!("{}/sections", prompt.path) }, + { "name": "groups", "type": "directory", "path": format!("{}/groups", prompt.path) }, + { "name": "variables", "type": "directory", "path": format!("{}/variables", prompt.path) } + ] + })) +} + +fn ls_prompt_child_dir(state: &AppState, prompt_dir: &str, child_dir: &str) -> AppResult { + let prompt = prompt_ref(state, prompt_dir)?; + let mount = prompt_child_mount(child_dir)?; + let records = record_refs(state, mount, Some(("presetId", prompt.id.as_str())))?; + let base = format!("{}/{}", prompt.path, child_dir); + let mut entries = vec![json!({ "name": "index.json", "type": "file", "path": format!("{base}/index.json") })]; + entries.extend(records.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "name": name, + "type": "file", + "path": format!("{base}/{name}"), + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + }) + })); + Ok(json!({ "path": base, "entries": entries })) +} + +fn ls_lorebook_dir(state: &AppState, lorebook_dir: &str) -> AppResult { + let lorebook = lorebook_ref(state, lorebook_dir)?; + Ok(json!({ + "path": lorebook.path, + "label": lorebook.label, + "entries": [ + { "name": "book.json", "type": "file", "path": format!("{}/book.json", lorebook.path) }, + { "name": "entries", "type": "directory", "path": format!("{}/entries", lorebook.path) } + ] + })) +} + +fn ls_lorebook_entries_dir(state: &AppState, lorebook_dir: &str) -> AppResult { + let lorebook = lorebook_ref(state, lorebook_dir)?; + let records = record_refs(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())))?; + let base = format!("{}/entries", lorebook.path); + let mut entries = vec![json!({ "name": "index.json", "type": "file", "path": format!("{base}/index.json") })]; + entries.extend(records.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "name": name, + "type": "file", + "path": format!("{base}/{name}"), + "ref": format!("{}-{:03}", LOREBOOK_ENTRY_MOUNT.prefix, record.ordinal), + "label": record.label, + }) + })); + Ok(json!({ "path": base, "entries": entries })) +} + +fn flat_index_content(state: &AppState, mount: &MariMount) -> AppResult { + let items = record_refs(state, mount, None)?.into_iter().map(index_item).collect::>(); + pretty(json!({ + "collection": mount.collection, + "label": mount.label, + "count": items.len(), + "idPolicy": "Paths and refs are user-friendly aliases. Internal storage ids are hidden in listings.", + "items": items, + })) +} + +fn prompt_index_content(state: &AppState) -> AppResult { + let mount = root_mount("prompts")?; + let items = record_refs(state, mount, None)?.into_iter().map(|record| json!({ + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + "path": record.path, + "presetPath": format!("{}/preset.json", record.path), + "sectionsPath": format!("{}/sections", record.path), + "groupsPath": format!("{}/groups", record.path), + "variablesPath": format!("{}/variables", record.path), + })).collect::>(); + pretty(json!({ + "collection": "prompts", + "label": "Prompt presets", + "count": items.len(), + "organization": "Prompt sections, groups, and variables are nested under each preset to avoid duplicating prompt internals at the library root.", + "items": items, + })) +} + +fn lorebook_index_content(state: &AppState) -> AppResult { + let mount = root_mount("lorebooks")?; + let items = record_refs(state, mount, None)?.into_iter().map(|record| json!({ + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + "path": record.path, + "bookPath": format!("{}/book.json", record.path), + "entriesPath": format!("{}/entries", record.path), + })).collect::>(); + pretty(json!({ + "collection": "lorebooks", + "label": "Lorebooks", + "count": items.len(), + "organization": "Lorebook entries are nested under their owning lorebook to avoid duplicating child records at the library root.", + "items": items, + })) +} + +fn prompt_child_index_content(state: &AppState, prompt_dir: &str, child_dir: &str) -> AppResult { + let prompt = prompt_ref(state, prompt_dir)?; + let mount = prompt_child_mount(child_dir)?; + let base = format!("{}/{}", prompt.path, child_dir); + let items = record_refs(state, mount, Some(("presetId", prompt.id.as_str())))?.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "ref": format!("{}-{:03}", mount.prefix, record.ordinal), + "label": record.label, + "path": format!("{base}/{name}"), + }) + }).collect::>(); + pretty(json!({ + "collection": mount.collection, + "parentPreset": prompt.label, + "count": items.len(), + "items": items, + })) +} + +fn lorebook_entries_index_content(state: &AppState, lorebook_dir: &str) -> AppResult { + let lorebook = lorebook_ref(state, lorebook_dir)?; + let base = format!("{}/entries", lorebook.path); + let items = record_refs(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())))?.into_iter().map(|record| { + let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); + json!({ + "ref": format!("{}-{:03}", LOREBOOK_ENTRY_MOUNT.prefix, record.ordinal), + "label": record.label, + "path": format!("{base}/{name}"), + }) + }).collect::>(); + pretty(json!({ + "collection": LOREBOOK_ENTRY_MOUNT.collection, + "parentLorebook": lorebook.label, + "count": items.len(), + "items": items, + })) +} + +fn read_prompt_record(state: &AppState, prompt_dir: &str) -> AppResult { + let prompt = prompt_ref(state, prompt_dir)?; + state.storage.get("prompts", &prompt.id)?.ok_or_else(|| AppError::not_found(format!("No prompt preset at {}", prompt.path))) +} + +fn read_lorebook_record(state: &AppState, lorebook_dir: &str) -> AppResult { + let lorebook = lorebook_ref(state, lorebook_dir)?; + state.storage.get("lorebooks", &lorebook.id)?.ok_or_else(|| AppError::not_found(format!("No lorebook at {}", lorebook.path))) +} + +fn read_prompt_child_record(state: &AppState, prompt_dir: &str, child_dir: &str, filename: &str) -> AppResult { + let prompt = prompt_ref(state, prompt_dir)?; + let mount = prompt_child_mount(child_dir)?; + let id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename)?; + let row = state.storage.get(mount.collection, &id)?.ok_or_else(|| AppError::not_found(format!("No prompt child record for {filename}")))?; + if row.get("presetId").and_then(Value::as_str) != Some(prompt.id.as_str()) { + return Err(AppError::not_found(format!("No {child_dir} record named {filename} under {}", prompt.label))); + } + pretty(row) +} + +fn read_lorebook_entry_record(state: &AppState, lorebook_dir: &str, filename: &str) -> AppResult { + let lorebook = lorebook_ref(state, lorebook_dir)?; + let id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename)?; + let row = state.storage.get("lorebook-entries", &id)?.ok_or_else(|| AppError::not_found(format!("No lorebook entry record for {filename}")))?; + if row.get("lorebookId").and_then(Value::as_str) != Some(lorebook.id.as_str()) { + return Err(AppError::not_found(format!("No entry named {filename} under {}", lorebook.label))); + } + pretty(row) +} + +fn record_refs(state: &AppState, mount: &MariMount, filter: Option<(&str, &str)>) -> AppResult> { + let mut rows = filtered_rows(state, mount.collection, filter)?; + rows.sort_by(|a, b| { + let a_display = record_label(a, mount).unwrap_or_else(|| row_id(a).unwrap_or_default()).to_ascii_lowercase(); + let b_display = record_label(b, mount).unwrap_or_else(|| row_id(b).unwrap_or_default()).to_ascii_lowercase(); + a_display.cmp(&b_display).then_with(|| row_id(a).cmp(&row_id(b))) + }); + Ok(rows.iter().enumerate().map(|(index, row)| { + let ordinal = index + 1; + let id = row_id(row).unwrap_or_else(|| format!("missing-id-{ordinal}")); + let label = record_label(row, mount).unwrap_or_else(|| format!("{} {}", singular_label(mount.label), ordinal)); + let path = format!("/library/{}/{}", mount.dir, alias_name(mount, ordinal, &label, false)); + MariRecordRef { ordinal, id, label, path } + }).collect()) +} + +fn filtered_rows(state: &AppState, collection: &str, filter: Option<(&str, &str)>) -> AppResult> { + let rows = state.storage.list(collection)?; + Ok(match filter { + Some((key, expected)) => rows.into_iter().filter(|row| row.get(key).and_then(Value::as_str) == Some(expected)).collect(), + None => rows, + }) +} + +fn prompt_ref(state: &AppState, prompt_dir: &str) -> AppResult { + resolve_parent_ref(state, root_mount("prompts")?, prompt_dir) +} + +fn lorebook_ref(state: &AppState, lorebook_dir: &str) -> AppResult { + resolve_parent_ref(state, root_mount("lorebooks")?, lorebook_dir) +} + +fn resolve_parent_ref(state: &AppState, mount: &MariMount, dir_name: &str) -> AppResult { + let records = record_refs(state, mount, None)?; + if let Some(ordinal) = alias_ordinal(dir_name, mount.prefix) { + return records.into_iter().find(|record| record.ordinal == ordinal).ok_or_else(|| AppError::not_found(format!("No {} record for alias {}-{:03}", mount.label, mount.prefix, ordinal))); + } + records.into_iter().find(|record| record.id == dir_name).ok_or_else(|| AppError::not_found(format!("No {} record named {dir_name}", mount.label))) +} + +fn resolve_record_id_from_file_name(state: &AppState, mount: &MariMount, filter: Option<(&str, &str)>, filename: &str) -> AppResult { + let without_ext = filename.strip_suffix(".json").unwrap_or(filename); + if let Some(ordinal) = alias_ordinal(without_ext, mount.prefix) { + return record_refs(state, mount, filter)?.into_iter().find(|record| record.ordinal == ordinal).map(|record| record.id).ok_or_else(|| AppError::not_found(format!("No {} record for alias {}-{:03}", mount.label, mount.prefix, ordinal))); + } + let id = without_ext.rsplit_once("__").map(|(_, id)| id).unwrap_or(without_ext).trim(); + if id.is_empty() { + return Err(AppError::invalid_input("read path is missing a record alias")); + } + Ok(id.to_string()) +} + +fn upsert_record(state: &AppState, collection: &str, id: Option, content: Value) -> AppResult { + let mut object = ensure_json_object(content)?; + if let Some(id) = id.filter(|id| !id.trim().is_empty()) { + object.insert("id".to_string(), Value::String(id.clone())); + state.storage.upsert_with_id(collection, &id, Value::Object(object)) + } else { + state.storage.create(collection, Value::Object(object)) + } +} + +fn merge_for_parent_file(state: &AppState, collection: &str, id: Option<&str>, content: Value, preserve_keys: &[&str]) -> AppResult { + let mut next = ensure_json_object(content)?; + if let Some(id) = id { + if let Some(existing) = state.storage.get(collection, id)? { + if let Some(existing_object) = existing.as_object() { + for key in preserve_keys { + if let Some(value) = existing_object.get(*key) { + next.insert((*key).to_string(), value.clone()); + } + } + } + } + } + Ok(Value::Object(next)) +} + +fn ensure_json_object(value: Value) -> AppResult> { + match value { + Value::Object(object) => Ok(object), + _ => Err(AppError::invalid_input("write content must be a JSON object")), + } +} + +fn flat_record_path(state: &AppState, mount: &MariMount, row: &Value) -> AppResult { + let id = row_id(row).ok_or_else(|| AppError::invalid_input("written record is missing an id"))?; + record_refs(state, mount, None)?.into_iter().find(|record| record.id == id).map(|record| record.path).ok_or_else(|| AppError::not_found("written record path could not be resolved")) +} + +fn prompt_record_path(state: &AppState, row: &Value, file_name: &str) -> AppResult { + let prompt_mount = root_mount("prompts")?; + Ok(format!("{}/{}", flat_record_path(state, prompt_mount, row)?, file_name)) +} + +fn lorebook_record_path(state: &AppState, row: &Value, file_name: &str) -> AppResult { + let lorebook_mount = root_mount("lorebooks")?; + Ok(format!("{}/{}", flat_record_path(state, lorebook_mount, row)?, file_name)) +} + +fn child_record_path(state: &AppState, parent: &MariRecordRef, mount: &MariMount, row: &Value) -> AppResult { + let id = row_id(row).ok_or_else(|| AppError::invalid_input("written child record is missing an id"))?; + let parent_key = if mount.collection == "lorebook-entries" { "lorebookId" } else { "presetId" }; + let base = format!("{}/{}", parent.path, mount.dir); + record_refs(state, mount, Some((parent_key, parent.id.as_str())))? + .into_iter() + .find(|record| record.id == id) + .and_then(|record| record.path.rsplit('/').next().map(|name| format!("{base}/{name}"))) + .ok_or_else(|| AppError::not_found("written child record path could not be resolved")) +} + +fn index_item(record: MariRecordRef) -> Value { + let prefix = record.path.rsplit('/').next().and_then(|name| name.split('-').next()).unwrap_or("record"); + json!({ + "ref": format!("{}-{:03}", prefix, record.ordinal), + "label": record.label, + "path": record.path, + }) +} + +fn strip_prompt_children(value: Value) -> Value { + strip_keys(value, &["sectionOrder", "groupOrder", "variableGroups", "variableValues", "sections", "groups", "variables"]) +} + +fn strip_lorebook_children(value: Value) -> Value { + strip_keys(value, &["entries", "folders"]) +} + +fn strip_keys(value: Value, keys: &[&str]) -> Value { + match value { + Value::Object(mut object) => { + for key in keys { + object.remove(*key); + } + Value::Object(object) + } + other => other, + } +} + +fn schema_entries() -> Vec { + vec![ + json!({ "name": "characters.json", "path": "/schema/characters.json", "type": "file" }), + json!({ "name": "character-groups.json", "path": "/schema/character-groups.json", "type": "file" }), + json!({ "name": "personas.json", "path": "/schema/personas.json", "type": "file" }), + json!({ "name": "persona-groups.json", "path": "/schema/persona-groups.json", "type": "file" }), + json!({ "name": "lorebooks.json", "path": "/schema/lorebooks.json", "type": "file" }), + json!({ "name": "lorebook-entries.json", "path": "/schema/lorebook-entries.json", "type": "file" }), + json!({ "name": "prompts.json", "path": "/schema/prompts.json", "type": "file" }), + json!({ "name": "prompt-sections.json", "path": "/schema/prompt-sections.json", "type": "file" }), + json!({ "name": "prompt-groups.json", "path": "/schema/prompt-groups.json", "type": "file" }), + json!({ "name": "prompt-variables.json", "path": "/schema/prompt-variables.json", "type": "file" }), + ] +} + +fn schema_content(filename: &str) -> AppResult { + let stem = filename.strip_suffix(".json").ok_or_else(|| AppError::not_found(format!("No schema file: /schema/{filename}")))?; + let notes = match stem { + "prompts" => "Prompt presets are directories. Read preset.json for preset metadata; sections, groups, and variables are nested child directories and are not duplicated in preset.json.", + "lorebooks" => "Lorebooks are directories. Read book.json for book metadata; entries are nested under entries/ and are not duplicated in book.json.", + "prompt-sections" | "prompt-groups" | "prompt-variables" => "Prompt internals are only listed under their owning preset at /library/prompts//.", + "lorebook-entries" => "Lorebook entries are only listed under their owning lorebook at /library/lorebooks//entries/.", + _ => "Records are exposed through user-friendly alias paths. This workspace is read-only.", + }; + pretty(json!({ "schema": stem, "notes": notes })) +} + +fn normalize_path(path: &str) -> AppResult { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Ok("/".to_string()); + } + if trimmed.contains('\\') || trimmed.contains(':') || trimmed.split('/').any(|part| part == "..") { + return Err(AppError::invalid_input("Professor Mari paths must stay inside the virtual workspace")); + } + let normalized = format!("/{}", trimmed.trim_matches('/')); + Ok(if normalized == "/" { "/".to_string() } else { normalized }) +} + +fn parts(path: &str) -> Vec<&str> { + path.trim_matches('/').split('/').filter(|part| !part.is_empty()).collect() +} + +fn root_mount(dir: &str) -> AppResult<&'static MariMount> { + ROOT_MOUNTS.iter().find(|mount| mount.dir == dir).ok_or_else(|| AppError::not_found(format!("No such Professor Mari library directory: {dir}"))) +} + +fn prompt_child_mount(dir: &str) -> AppResult<&'static MariMount> { + PROMPT_CHILD_MOUNTS.iter().find(|mount| mount.dir == dir).ok_or_else(|| AppError::not_found(format!("No such prompt child directory: {dir}"))) +} + +fn row_id(row: &Value) -> Option { + row.get("id").and_then(Value::as_str).filter(|id| !id.trim().is_empty()).map(str::to_string) +} + +fn record_label(row: &Value, mount: &MariMount) -> Option { + let candidates: &[&[&str]] = match mount.collection { + "characters" => &[&["data", "name"]], + "personas" | "persona-groups" | "character-groups" | "lorebooks" | "lorebook-entries" | "prompts" | "prompt-sections" | "prompt-groups" => &[&["name"]], + "prompt-variables" => &[&["label"], &["name"], &["variableName"]], + _ => &[&["name"]], + }; + candidates.iter().find_map(|path| nested_string_value(row, path)).map(|value| value.trim().to_string()).filter(|value| !value.is_empty()).filter(|value| !looks_like_internal_id(value)).map(|value| value.to_string()) +} + +fn nested_string_value(value: &Value, path: &[&str]) -> Option { + if path.is_empty() { + return value.as_str().map(str::to_string); + } + let mut current = value; + for (index, key) in path.iter().enumerate() { + current = current.get(*key)?; + if let Value::String(text) = current { + if index + 1 == path.len() { + return Some(text.clone()); + } + let parsed = serde_json::from_str::(text).ok()?; + return nested_string_value(&parsed, &path[index + 1..]); + } + } + current.as_str().map(str::to_string) +} + +fn alias_name(mount: &MariMount, ordinal: usize, label: &str, extension: bool) -> String { + let base = format!("{}-{:03}-{}", mount.prefix, ordinal, slug(label)); + if extension { format!("{base}.json") } else { base } +} + +fn alias_ordinal(value: &str, prefix: &str) -> Option { + let rest = value.strip_prefix(prefix)?.strip_prefix('-')?; + let digits = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect::(); + if digits.is_empty() { None } else { digits.parse::().ok().filter(|ordinal| *ordinal > 0) } +} + +fn slug(value: &str) -> String { + let slug = value.chars().map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' }).collect::().split('-').filter(|part| !part.is_empty()).collect::>().join("-"); + if slug.is_empty() { "record".to_string() } else { slug.chars().take(48).collect() } +} + +fn looks_like_internal_id(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.len() >= 20 && trimmed.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { + let has_digit = trimmed.chars().any(|ch| ch.is_ascii_digit()); + let has_alpha = trimmed.chars().any(|ch| ch.is_ascii_alphabetic()); + return has_digit && has_alpha && !trimmed.contains(' '); + } + false +} + +fn singular_label(label: &str) -> String { + match label { + "Characters" => "Character".to_string(), + "Personas" => "Persona".to_string(), + "Lorebooks" => "Lorebook".to_string(), + "Lorebook entries" => "Lorebook entry".to_string(), + "Prompt presets" => "Prompt preset".to_string(), + "Prompt sections" => "Prompt section".to_string(), + "Prompt groups" => "Prompt group".to_string(), + "Prompt variables" => "Prompt variable".to_string(), + "Character groups" => "Character group".to_string(), + "Persona groups" => "Persona group".to_string(), + other => other.trim_end_matches('s').to_string(), + } +} + +fn pretty(value: Value) -> AppResult { + serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_fs_serialize_failed", error.to_string())) +} + +fn help_text() -> String { + "# Professor Mari virtual workspace\n\nThis is a read-only virtual filesystem backed by Marinara's creative library.\n\nAvailable commands:\n- ls({ path }) lists directories.\n- read({ path, offset?, limit? }) reads JSON or markdown files.\n\nTop-level library folders are characters, character-groups, personas, persona-groups, lorebooks, and prompts. Prompt sections/groups/variables live under their owning prompt preset. Lorebook entries live under their owning lorebook. Parent files such as preset.json and book.json intentionally omit child records to avoid duplication.\n".to_string() +} diff --git a/src/features/shell/mari/components/ProfessorMariSurface.tsx b/src/features/shell/mari/components/ProfessorMariSurface.tsx index a9ea67006..cdaab2cdb 100644 --- a/src/features/shell/mari/components/ProfessorMariSurface.tsx +++ b/src/features/shell/mari/components/ProfessorMariSurface.tsx @@ -80,6 +80,17 @@ function toConversationMessage(message: MariMessage): Message { }; } +function formatErrorDetails(error: unknown) { + if (!error || typeof error !== "object") return null; + const record = error as Record; + const details = "details" in record ? record.details : record; + try { + return JSON.stringify(details, null, 2); + } catch { + return String(details); + } +} + export function ProfessorMariSurface() { const { data: rawConnections } = useConnections(); const { data: rawPersonas } = usePersonas(); @@ -95,6 +106,7 @@ export function ProfessorMariSurface() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); + const [sendErrorDetails, setSendErrorDetails] = useState(null); const fileInputRef = useRef(null); const inputRef = useRef(null); const messagesEndRef = useRef(null); @@ -202,6 +214,7 @@ export function ProfessorMariSurface() { setDraft(""); setAttachments([]); setSendError(null); + setSendErrorDetails(null); setSending(true); requestAnimationFrame(() => inputRef.current?.focus()); let response; @@ -234,7 +247,9 @@ export function ProfessorMariSurface() { mariApi, ); } catch (error) { + console.error("Professor Mari failed to respond", error); setSendError(error instanceof Error ? error.message : "Professor Mari failed to respond."); + setSendErrorDetails(formatErrorDetails(error)); setSending(false); return; } @@ -312,7 +327,17 @@ export function ProfessorMariSurface() { {sending && (
Professor Mari is thinking...
)} - {sendError &&
{sendError}
} + {sendError && ( +
+
{sendError}
+ {sendErrorDetails && ( +
+ Debug details +
{sendErrorDetails}
+
+ )} +
+ )}
From c38ab36b78baf04d8394dc4070d30bdafbd287f1 Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 15:27:36 -0500 Subject: [PATCH 2/8] added some mari updates --- src-tauri/Cargo.lock | 310 ++- src-tauri/Cargo.toml | 1 + src-tauri/crates/llm/src/lib.rs | 4 +- src-tauri/src/commands/storage.rs | 2 - .../src/commands/storage/imports/marinara.rs | 5 +- .../commands/storage/imports/normalization.rs | 18 +- .../src/commands/storage/imports/service.rs | 4 +- src-tauri/src/commands/storage/mari.rs | 2152 +++++++++++++---- src-tauri/src/commands/storage/mari_fs.rs | 744 ------ .../src/commands/storage/profile/legacy.rs | 5 +- src-tauri/src/http_dispatch.rs | 8 +- 11 files changed, 1973 insertions(+), 1280 deletions(-) delete mode 100644 src-tauri/src/commands/storage/mari_fs.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a22753f4..54a63b330 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -25,7 +25,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -96,12 +96,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -524,6 +568,39 @@ dependencies = [ "vsimd", ] +[[package]] +name = "bashkit" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe1ecee86bc21d5c724060061ca37194b383dbda3a45ce97df72a59edb55f57" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bigdecimal", + "chrono", + "clap", + "fancy-regex 0.18.0", + "flate2", + "futures-core", + "futures-util", + "getrandom 0.4.2", + "hmac", + "md-5 0.11.0", + "num-traits", + "os_display", + "regex", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tower", + "unit-prefix", + "url", +] + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -533,6 +610,19 @@ dependencies = [ "scoped-tls", ] +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -584,6 +674,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -984,8 +1083,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1022,7 +1123,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1033,6 +1134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1041,8 +1143,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1060,6 +1176,18 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1092,6 +1220,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -1189,6 +1323,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1283,6 +1426,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1322,6 +1474,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -1602,8 +1763,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1927,6 +2100,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2573,6 +2757,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hstr" version = "3.0.5" @@ -2648,6 +2841,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2971,6 +3173,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -3137,7 +3345,7 @@ dependencies = [ "bytecount", "data-encoding", "email_address", - "fancy-regex", + "fancy-regex 0.17.0", "fraction", "getrandom 0.3.4", "idna", @@ -3226,6 +3434,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -3284,12 +3498,12 @@ dependencies = [ "indexmap 2.14.0", "itoa", "log", - "md-5", + "md-5 0.10.6", "nom", "nom_locate", "rand 0.9.4", "rangemap", - "sha2", + "sha2 0.10.9", "stringprep", "thiserror 2.0.18", "ttf-parser", @@ -3332,6 +3546,7 @@ dependencies = [ "autoagents", "axum", "base64 0.22.1", + "bashkit", "buttplug", "buttplug_core", "chrono", @@ -3349,7 +3564,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3423,7 +3638,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -3888,6 +4113,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -3928,6 +4159,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_display" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" +dependencies = [ + "unicode-width", +] + [[package]] name = "outref" version = "0.5.2" @@ -5309,8 +5549,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5320,8 +5571,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5471,7 +5733,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5825,7 +6087,7 @@ dependencies = [ "once_cell", "rustc-hash", "serde", - "sha1", + "sha1 0.10.6", "string_enum", "swc_atoms", "swc_common", @@ -6179,7 +6441,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -6905,7 +7167,7 @@ dependencies = [ "rand 0.9.4", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "url", "utf-8", @@ -7047,6 +7309,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "untrusted" version = "0.9.0" @@ -7128,6 +7396,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -8100,7 +8374,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dfca7b8e8..75a269e6c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tauri-build = { version = "2", features = [] } [dependencies] autoagents = { git = "https://github.com/liquidos-ai/AutoAgents.git", rev = "57ebeaa4e18989909013ebd58351b3ef6a5586e0", features = ["codeact"] } +bashkit = "0.7.1" axum = "0.7" base64 = "0.22" buttplug = "10.0.2" diff --git a/src-tauri/crates/llm/src/lib.rs b/src-tauri/crates/llm/src/lib.rs index 85cf82133..460c152d0 100644 --- a/src-tauri/crates/llm/src/lib.rs +++ b/src-tauri/crates/llm/src/lib.rs @@ -284,7 +284,9 @@ fn should_send_top_k(request: &LlmRequest) -> bool { fn provider_error_text(details: &Value) -> Option { [ details.pointer("/error/message").and_then(Value::as_str), - details.pointer("/response/error/message").and_then(Value::as_str), + details + .pointer("/response/error/message") + .and_then(Value::as_str), details.get("message").and_then(Value::as_str), details.pointer("/error").and_then(Value::as_str), details.pointer("/response/error").and_then(Value::as_str), diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs index 60fec1a63..7c037e136 100644 --- a/src-tauri/src/commands/storage.rs +++ b/src-tauri/src/commands/storage.rs @@ -48,8 +48,6 @@ pub(crate) mod llm; pub(crate) mod lorebook_images; #[path = "storage/mari.rs"] mod mari; -#[path = "storage/mari_fs.rs"] -mod mari_fs; #[path = "storage/media_uploads.rs"] mod media_uploads; #[path = "storage/profile.rs"] diff --git a/src-tauri/src/commands/storage/imports/marinara.rs b/src-tauri/src/commands/storage/imports/marinara.rs index 10a3da63e..526fa63d1 100644 --- a/src-tauri/src/commands/storage/imports/marinara.rs +++ b/src-tauri/src/commands/storage/imports/marinara.rs @@ -433,10 +433,7 @@ fn import_marinara_lorebook( // Pre-refactor stored `tags`/`characterIds`/`personaIds` as TEXT columns // (JSON-stringified arrays). Refactor expects real arrays — without this // normalize step the lorebook editor crashes on `formTags.map is not a function`. - normalize_legacy_text_array_fields( - &mut lorebook_data, - &["tags", "characterIds", "personaIds"], - ); + normalize_legacy_text_array_fields(&mut lorebook_data, &["tags", "characterIds", "personaIds"]); // Pre-refactor also stored bool columns as TEXT (`"false"` / `"true"`). // Refactor reads these directly, so `lorebook.isGlobal === "false"` is // truthy and the editor shows every scoped lorebook as global. diff --git a/src-tauri/src/commands/storage/imports/normalization.rs b/src-tauri/src/commands/storage/imports/normalization.rs index 71618e77f..804bee49e 100644 --- a/src-tauri/src/commands/storage/imports/normalization.rs +++ b/src-tauri/src/commands/storage/imports/normalization.rs @@ -98,7 +98,11 @@ pub(super) fn strip_stale_embedded_lorebook_pointer(data: &mut Value) { } } -pub(super) fn character_import_extensions(payload: &Value, data: &Value, embedded: Option<&Value>) -> Value { +pub(super) fn character_import_extensions( + payload: &Value, + data: &Value, + embedded: Option<&Value>, +) -> Value { let mut extensions = data .get("extensions") .and_then(Value::as_object) @@ -133,7 +137,11 @@ pub(super) fn character_import_extensions(payload: &Value, data: &Value, embedde Value::Object(extensions) } -pub(super) fn normalize_character_data(payload: &Value, tag_mode: &str, existing_tags: &[String]) -> Value { +pub(super) fn normalize_character_data( + payload: &Value, + tag_mode: &str, + existing_tags: &[String], +) -> Value { let data = source_character_data(payload); let embedded = embedded_lorebook(payload); let mut tags = string_array(data.get("tags")); @@ -282,7 +290,11 @@ pub(super) fn normalize_lorebook_entry(lorebook_id: &str, entry: &Value, index: }) } -pub(super) fn normalize_imported_lorebook_entry(lorebook_id: &str, entry: &Value, index: usize) -> Value { +pub(super) fn normalize_imported_lorebook_entry( + lorebook_id: &str, + entry: &Value, + index: usize, +) -> Value { let mut object = ensure_object(normalize_lorebook_entry(lorebook_id, entry, index)).unwrap_or_default(); if let Some(source) = entry.as_object() { diff --git a/src-tauri/src/commands/storage/imports/service.rs b/src-tauri/src/commands/storage/imports/service.rs index e3741ecb7..ba72d2c2c 100644 --- a/src-tauri/src/commands/storage/imports/service.rs +++ b/src-tauri/src/commands/storage/imports/service.rs @@ -17,13 +17,13 @@ mod payloads; mod st_preset; #[path = "timestamps.rs"] mod timestamps; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; use access::*; use marinara::*; use normalization::*; use payloads::*; use st_preset::*; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use timestamps::{apply_timestamp_overrides, timestamp_overrides_from_value}; fn create_lorebook_from_payload( diff --git a/src-tauri/src/commands/storage/mari.rs b/src-tauri/src/commands/storage/mari.rs index 8620ef522..06e3f1590 100644 --- a/src-tauri/src/commands/storage/mari.rs +++ b/src-tauri/src/commands/storage/mari.rs @@ -1,9 +1,73 @@ use super::llm::{llm_connection_from_value, resolve_llm_connection_for_request}; -use super::mari_fs; use crate::state::AppState; +use autoagents::async_trait; +use autoagents::core::agent::memory::SlidingWindowMemory; +use autoagents::core::agent::prebuilt::executor::ReActAgent; +use autoagents::core::agent::task::Task; +use autoagents::core::agent::{AgentBuilder, AgentDeriveT, DirectAgent}; +use autoagents::core::tool::{shared_tools_to_boxes, ToolCallError, ToolRuntime, ToolT}; +use autoagents::llm::chat::{ + ChatMessage, ChatProvider, ChatResponse, MessageType, SamplingOverrides, + StructuredOutputFormat, Tool, +}; +use autoagents::llm::completion::{CompletionProvider, CompletionRequest, CompletionResponse}; +use autoagents::llm::embedding::EmbeddingProvider; +use autoagents::llm::error::LLMError; +use autoagents::llm::models::{ModelListRequest, ModelListResponse, ModelsProvider}; +use autoagents::llm::LLMProvider; +use autoagents::llm::{FunctionCall, ToolCall}; +use autoagents::prelude::AgentHooks; +use bashkit::{ + async_trait as bashkit_async_trait, Bash, DirEntry, FileSystem, FileSystemExt, FileType, + InMemoryFs, Metadata, +}; use marinara_core::{AppError, AppResult}; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; +use tokio::sync::Mutex; + +const MARI_TEXT_ATTACHMENT_CHAR_LIMIT: usize = 60_000; +const MARI_TOOL_TEXT_LIMIT: usize = 32_000; +const MARI_METADATA_STRING_LIMIT: usize = 4_000; +const MARI_SYSTEM_PROMPT: &str = "You are Professor Mari, a coding-style agent inside a virtual Marinara workspace containing the user's creative library. Reply plainly and helpfully. Use tools to inspect /workspace/index.md and folders like /workspace/characters, /workspace/personas, /workspace/lorebooks, and /workspace/prompts before answering questions about the user's data. Visible paths use descriptive names; internal storage IDs are hidden and tracked by Marinara. File changes are staged for user review after your commands; do not ask for approval before making staged edits."; + +#[derive(Clone, AgentHooks)] +struct ProfessorMariAgent { + tools: Vec>, +} + +impl fmt::Debug for ProfessorMariAgent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProfessorMariAgent") + .field("tool_count", &self.tools.len()) + .finish() + } +} + +impl AgentDeriveT for ProfessorMariAgent { + type Output = String; + + fn description(&self) -> &str { + MARI_SYSTEM_PROMPT + } + + fn output_schema(&self) -> Option { + None + } + + fn name(&self) -> &str { + "professor_mari" + } + + fn tools(&self) -> Vec> { + shared_tools_to_boxes(&self.tools) + } +} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,6 +81,8 @@ struct MariPromptRequest { persona: Option, #[serde(default)] attachments: Vec, + #[serde(default)] + workspace_files: Vec, } #[derive(Debug, Deserialize)] @@ -47,9 +113,15 @@ struct MariAttachment { content: String, } +#[derive(Debug, Deserialize)] +struct MariWorkspaceFile { + path: String, + content: String, +} + pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppResult { - let input: MariPromptRequest = serde_json::from_value(body) - .map_err(|error| AppError::invalid_input(error.to_string()))?; + let input: MariPromptRequest = + serde_json::from_value(body).map_err(|error| AppError::invalid_input(error.to_string()))?; let connection_value = resolve_llm_connection_for_request( state, &json!({ @@ -57,606 +129,1686 @@ pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppR }), )?; let connection = llm_connection_from_value(&connection_value)?; - ensure_connection_supports_native_tools(&connection)?; - - let mut messages = vec![marinara_llm::LlmMessage { - role: "system".to_string(), - content: build_system_prompt(input.persona.as_ref()), - name: None, - images: Vec::new(), - tool_call_id: None, - tool_calls: None, - }]; - messages.push(marinara_llm::LlmMessage { - role: "user".to_string(), - content: build_task_prompt(&input), - name: None, - images: Vec::new(), - tool_call_id: None, - tool_calls: None, - }); + let (content, action) = run_mari_agent(state, connection, &input).await?; - let content = run_mari_tool_loop(state, connection, messages).await?; Ok(json!({ "content": content, "createdAt": chrono::Utc::now().to_rfc3339(), - "action": read_only_mari_action_contract(), + "action": action, })) } -async fn run_mari_tool_loop( +async fn run_mari_agent( state: &AppState, connection: marinara_llm::LlmConnection, - mut messages: Vec, -) -> AppResult { - const MAX_TOOL_ROUNDS: usize = 6; - let tools = mari_tool_definitions(); - let mut last_content = String::new(); + input: &MariPromptRequest, +) -> AppResult<(String, Value)> { + let workspace_seed = build_mari_workspace_seed(state)?; + let session = MariShellSession::new(input, workspace_seed).await?; + let tools = build_pi_like_tools(session.clone()); + let llm: Arc = Arc::new(MarinaraLlmProvider::new(connection)); + let agent = ReActAgent::with_max_turns(ProfessorMariAgent { tools }, 8); + let agent_handle = AgentBuilder::<_, DirectAgent>::new(agent) + .llm(llm) + .memory(Box::new(SlidingWindowMemory::new(12))) + .build() + .await + .map_err(|error| AppError::new("mari_agent_failed", error.to_string()))?; + + let result = agent_handle + .agent + .run(Task::new(build_task_prompt(input))) + .await + .map_err(|error| AppError::new("mari_agent_failed", error.to_string()))?; + let content = result.trim(); + let content = if content.is_empty() { + "I couldn't produce a response from the selected model.".to_string() + } else { + content.to_string() + }; + Ok((content, staged_mari_action_contract(&session).await?)) +} + +#[derive(Clone)] +struct MarinaraLlmProvider { + connection: marinara_llm::LlmConnection, +} - for _ in 0..MAX_TOOL_ROUNDS { +impl MarinaraLlmProvider { + fn new(connection: marinara_llm::LlmConnection) -> Self { + Self { connection } + } + + async fn complete_chat( + &self, + messages: &[ChatMessage], + sampling: Option<&SamplingOverrides>, + tools: Option<&[Tool]>, + ) -> Result { + let request_tools = tools + .unwrap_or(&[]) + .iter() + .map(|tool| serde_json::to_value(&tool.function).unwrap_or_else(|_| json!({}))) + .collect(); let response = marinara_llm::complete_rich(marinara_llm::LlmRequest { - connection: connection.clone(), - messages: messages.clone(), - parameters: json!({ - "temperature": 0.35, - "maxTokens": 2048, - }), - tools: tools.clone(), + connection: self.connection.clone(), + messages: map_autoagents_messages(messages), + parameters: sampling_parameters(sampling), + tools: request_tools, }) .await - .map_err(|error| { - let debug_error = format_app_error_for_debug(&error); - log::error!("Professor Mari LLM request failed: {debug_error}"); - AppError::new("mari_agent_failed", tool_call_error_message(&debug_error)) - })?; - - last_content = response.content.trim().to_string(); - if response.tool_calls.is_empty() { - return Ok(if last_content.is_empty() { - "I couldn't produce a response from the selected model.".to_string() - } else { - last_content - }); - } - - messages.push(marinara_llm::LlmMessage { - role: "assistant".to_string(), + .map_err(|error| LLMError::ProviderError(format_app_error_for_debug(&error)))?; + Ok(MarinaraChatResponse { content: response.content, + tool_calls: map_marinara_tool_calls(response.tool_calls), + }) + } +} + +impl LLMProvider for MarinaraLlmProvider {} + +#[async_trait] +impl ChatProvider for MarinaraLlmProvider { + async fn chat_with_tools( + &self, + messages: &[ChatMessage], + tools: Option<&[Tool]>, + _json_schema: Option, + ) -> Result, LLMError> { + Ok(Box::new(self.complete_chat(messages, None, tools).await?)) + } + + async fn chat_with_tools_and_sampling( + &self, + messages: &[ChatMessage], + tools: Option<&[Tool]>, + _json_schema: Option, + sampling: Option<&SamplingOverrides>, + ) -> Result, LLMError> { + Ok(Box::new( + self.complete_chat(messages, sampling, tools).await?, + )) + } +} + +#[async_trait] +impl CompletionProvider for MarinaraLlmProvider { + async fn complete( + &self, + req: &CompletionRequest, + _json_schema: Option, + ) -> Result { + let message = marinara_llm::LlmMessage { + role: "user".to_string(), + content: req.prompt.clone(), name: None, images: Vec::new(), tool_call_id: None, - tool_calls: Some(Value::Array( - response - .tool_calls - .iter() - .map(normalize_tool_call_for_chat_history) - .collect(), - )), - }); + tool_calls: None, + }; + let response = marinara_llm::complete(marinara_llm::LlmRequest { + connection: self.connection.clone(), + messages: vec![message], + parameters: json!({ + "temperature": req.temperature, + "maxTokens": req.max_tokens, + }), + tools: Vec::new(), + }) + .await + .map_err(|error| LLMError::ProviderError(format_app_error_for_debug(&error)))?; + Ok(CompletionResponse { text: response }) + } +} - for call in response.tool_calls { - let result = execute_mari_tool_call(state, &call); - messages.push(marinara_llm::LlmMessage { - role: "tool".to_string(), - content: result.to_string(), +#[async_trait] +impl EmbeddingProvider for MarinaraLlmProvider { + async fn embed(&self, _input: Vec) -> Result>, LLMError> { + Err(LLMError::ProviderError( + "Marinara Professor Mari does not support embeddings".to_string(), + )) + } +} + +#[async_trait] +impl ModelsProvider for MarinaraLlmProvider { + async fn list_models( + &self, + _request: Option<&ModelListRequest>, + ) -> Result, LLMError> { + Err(LLMError::ProviderError( + "Marinara Professor Mari does not list models through AutoAgents".to_string(), + )) + } +} + +#[derive(Debug)] +struct MarinaraChatResponse { + content: String, + tool_calls: Vec, +} + +impl ChatResponse for MarinaraChatResponse { + fn text(&self) -> Option { + Some(self.content.clone()) + } + + fn tool_calls(&self) -> Option> { + (!self.tool_calls.is_empty()).then(|| self.tool_calls.clone()) + } +} + +impl fmt::Display for MarinaraChatResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.content) + } +} + +fn map_autoagents_messages(messages: &[ChatMessage]) -> Vec { + let mut mapped = Vec::new(); + for message in messages { + match &message.message_type { + MessageType::ToolUse(tool_calls) => mapped.push(marinara_llm::LlmMessage { + role: "assistant".to_string(), + content: message.content.clone(), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: serde_json::to_value(tool_calls).ok(), + }), + MessageType::ToolResult(tool_results) => { + for result in tool_results { + mapped.push(marinara_llm::LlmMessage { + role: "tool".to_string(), + content: result.function.arguments.clone(), + name: Some(result.function.name.clone()), + images: Vec::new(), + tool_call_id: Some(result.id.clone()), + tool_calls: None, + }); + } + } + MessageType::ImageURL(url) if is_http_image_url(url) => { + mapped.push(marinara_llm::LlmMessage { + role: message.role.to_string(), + content: message.content.clone(), + name: None, + images: vec![url.clone()], + tool_call_id: None, + tool_calls: None, + }) + } + _ => mapped.push(marinara_llm::LlmMessage { + role: message.role.to_string(), + content: message.content.clone(), name: None, images: Vec::new(), - tool_call_id: tool_call_id(&call), + tool_call_id: None, tool_calls: None, - }); + }), } } + mapped +} - let response = marinara_llm::complete_rich(marinara_llm::LlmRequest { - connection, - messages, - parameters: json!({ - "temperature": 0.35, - "maxTokens": 2048, - }), - tools: Vec::new(), - }) - .await - .map_err(|error| AppError::new("mari_agent_failed", format_app_error_for_debug(&error)))?; +fn map_marinara_tool_calls(values: Vec) -> Vec { + values + .into_iter() + .filter_map(|value| { + let id = value + .get("id") + .or_else(|| value.get("call_id")) + .and_then(Value::as_str) + .unwrap_or("tool_call") + .to_string(); + let function = value.get("function").unwrap_or(&value); + let name = function + .get("name") + .or_else(|| value.get("name")) + .and_then(Value::as_str)? + .to_string(); + let arguments = function + .get("arguments") + .or_else(|| value.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}") + .to_string(); + Some(ToolCall { + id, + call_type: value + .get("type") + .and_then(Value::as_str) + .unwrap_or("function") + .to_string(), + function: FunctionCall { name, arguments }, + }) + }) + .collect() +} - let final_content = response.content.trim(); - if final_content.is_empty() { - Ok(last_content) - } else { - Ok(final_content.to_string()) - } -} - -fn execute_mari_tool_call(state: &AppState, call: &Value) -> Value { - let name = tool_call_name(call); - let args = tool_call_arguments(call); - let result = match name.as_str() { - "bash" => { - let command = args.get("command").and_then(Value::as_str).unwrap_or(""); - Ok(run_virtual_bash(state, command)) - } - "ls" => { - let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); - mari_fs::ls(state, path) - } - "read" => { - let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); - let offset = args.get("offset").and_then(Value::as_u64).map(|value| value as usize); - let limit = args.get("limit").and_then(Value::as_u64).map(|value| value as usize); - mari_fs::read(state, path, offset, limit) - } - "edit" => { - let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); - let edits = args - .get("edits") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(|item| { - Some(( - item.get("oldText")?.as_str()?.to_string(), - item.get("newText")?.as_str()?.to_string(), - )) - }) - .collect::>() - }) - .unwrap_or_default(); - mari_fs::edit(state, path, &edits) +fn sampling_parameters(sampling: Option<&SamplingOverrides>) -> Value { + let mut params = json!({ + "temperature": 0.35, + "maxTokens": 2048, + }); + if let Some(sampling) = sampling { + if let Some(temperature) = sampling.temperature { + params["temperature"] = json!(temperature); } - "write" => { - let path = args.get("path").and_then(Value::as_str).unwrap_or("/"); - let content = args.get("content").and_then(Value::as_str).unwrap_or(""); - mari_fs::write(state, path, content) + if let Some(max_tokens) = sampling.max_tokens { + params["maxTokens"] = json!(max_tokens); } - _ => Err(AppError::invalid_input(format!("Unknown Professor Mari tool: {name}"))), - }; + if let Some(top_p) = sampling.top_p { + params["topP"] = json!(top_p); + } + } + params +} + +fn build_task_prompt(input: &MariPromptRequest) -> String { + let mut sections = vec![format!("System instructions:\n{MARI_SYSTEM_PROMPT}")]; + + if let Some(persona) = build_persona_context(input.persona.as_ref()) { + sections.push(format!("Selected user persona:\n{persona}")); + } + + let history = input + .messages + .iter() + .rev() + .take(16) + .collect::>() + .into_iter() + .rev() + .filter_map(|message| { + let content = message.content.trim(); + (!content.is_empty()).then(|| format!("{}: {content}", message.role)) + }) + .collect::>() + .join("\n"); + if !history.is_empty() { + sections.push(format!("Conversation history:\n{history}")); + } - match result { - Ok(value) => json!({ "ok": true, "result": value }), - Err(error) => json!({ "ok": false, "error": error.to_string() }), + if !input.attachments.is_empty() { + sections.push(format!( + "Latest turn attachments:\n{}", + attachment_summary(&input.attachments) + )); } + + sections.push(format!( + "Latest user message:\n{}", + input.user_message.trim() + )); + sections.join("\n\n") } -fn mari_tool_definitions() -> Vec { - vec![ - json!({ - "name": "bash", - "description": "Run a safe virtual shell command in Professor Mari's Marinara workspace. This is not the host shell. Supports ls, cat/read, head, tail, grep, find, write, rm, cp, and mv with simple pipes over virtual workspace text only.", - "parameters": { - "type": "object", - "properties": { - "command": { "type": "string", "description": "Safe virtual shell command, e.g. ls /library, cat /library/characters/index.json, find /library -name '*makima*', grep -R Makima /library, write /library/personas/new.json {\"name\":\"New\"}." } - }, - "required": ["command"] +fn build_persona_context(persona: Option<&MariPersonaContext>) -> Option { + let persona = persona?; + let text = [ + ("Name", persona.name.as_deref()), + ("Comment", persona.comment.as_deref()), + ("Description", persona.description.as_deref()), + ("Personality", persona.personality.as_deref()), + ("Scenario", persona.scenario.as_deref()), + ("Backstory", persona.backstory.as_deref()), + ("Appearance", persona.appearance.as_deref()), + ] + .into_iter() + .filter_map(|(label, value)| { + let value = value?.trim(); + (!value.is_empty()).then(|| format!("{label}: {value}")) + }) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) +} + +fn attachment_summary(attachments: &[MariAttachment]) -> String { + attachments + .iter() + .map(|attachment| { + if attachment.r#type.to_ascii_lowercase().starts_with("image/") { + return format!( + "- {} ({}, {} bytes): image attachment withheld from the LLM to avoid sending full base64 data.", + attachment.name, attachment.r#type, attachment.size + ); } - }), - json!({ - "name": "edit", - "description": "Edit a file in Professor Mari's virtual Marinara workspace using exact text replacements. Each oldText must match exactly once in the file content returned by read.", - "parameters": { - "type": "object", - "properties": { - "path": { "type": "string", "description": "Virtual file path to edit. Use read first, then copy exact oldText from the returned content." }, - "edits": { - "type": "array", - "items": { - "type": "object", - "properties": { - "oldText": { "type": "string", "description": "Exact text to replace. Must match once." }, - "newText": { "type": "string", "description": "Replacement text." } - }, - "required": ["oldText", "newText"] - } - } - }, - "required": ["path", "edits"] + + let content = attachment.content.trim(); + let content = if content.chars().count() > MARI_TEXT_ATTACHMENT_CHAR_LIMIT { + format!( + "{}\n\n[Attachment truncated after {} characters.]", + content.chars().take(MARI_TEXT_ATTACHMENT_CHAR_LIMIT).collect::(), + MARI_TEXT_ATTACHMENT_CHAR_LIMIT + ) + } else { + content.to_string() + }; + format!( + "File: {}\nType: {}\nSize: {}\nContent:\n{}", + attachment.name, attachment.r#type, attachment.size, content + ) + }) + .collect::>() + .join("\n\n---\n\n") +} + +fn is_http_image_url(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) + && (lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + || lower.ends_with(".webp")) +} + +#[derive(Debug, Clone)] +struct MariWorkspaceFileRecord { + path: String, + content: String, +} + +#[derive(Debug, Clone)] +struct MariWorkspaceBinding { + entity: String, + id: String, + field: Option, +} + +#[derive(Debug, Clone, Default)] +struct MariWorkspaceSeed { + files: Vec, + bindings: BTreeMap, +} + +#[derive(Debug, Default)] +struct PathAllocator { + used: BTreeSet, +} + +impl PathAllocator { + fn child(&mut self, parent: &str, preferred: &str, fallback: &str) -> String { + let base = sanitize_path_segment( + first_non_empty(&[Some(preferred), Some(fallback)]).unwrap_or(fallback), + ); + for index in 1.. { + let name = if index == 1 { + base.clone() + } else { + format!("{base} ({index})") + }; + let path = format!("{}/{}", parent.trim_end_matches('/'), name); + if self.used.insert(path.clone()) { + return path; } - }), - json!({ - "name": "write", - "description": "Write a complete JSON file in Professor Mari's virtual Marinara workspace. Existing paths update storage records; new valid library paths create records and auto-assign them to the parent folder/preset/lorebook when applicable.", - "parameters": { - "type": "object", - "properties": { - "path": { "type": "string", "description": "Virtual file path to write." }, - "content": { "type": "string", "description": "Complete JSON content to write." } - }, - "required": ["path", "content"] + } + unreachable!() + } +} + +fn build_mari_workspace_seed(state: &AppState) -> AppResult { + let mut seed = MariWorkspaceSeed::default(); + let mut allocator = PathAllocator::default(); + for root in [ + "/workspace/characters", + "/workspace/character-groups", + "/workspace/personas", + "/workspace/persona-groups", + "/workspace/lorebooks", + "/workspace/prompts", + ] { + allocator.used.insert(root.to_string()); + } + + let characters = list_storage_or_empty(state, "characters")?; + add_flat_collection( + &mut seed, + &mut allocator, + "characters", + "/workspace/characters", + "Untitled Character", + &characters, + &[ + "description", + "personality", + "scenario", + "firstMessage", + "first_message", + "first_mes", + "greeting", + "mes_example", + "exampleDialogue", + "systemPrompt", + "creatorNotes", + "creator_notes", + "data.description", + "data.personality", + "data.scenario", + "data.first_mes", + "data.mes_example", + "data.creator_notes", + "data.system_prompt", + "data.post_history_instructions", + ], + )?; + + let character_groups = list_storage_or_empty(state, "character-groups")?; + add_flat_collection( + &mut seed, + &mut allocator, + "character-groups", + "/workspace/character-groups", + "Untitled Character Group", + &character_groups, + &["description", "notes"], + )?; + + let personas = list_storage_or_empty(state, "personas")?; + add_flat_collection( + &mut seed, + &mut allocator, + "personas", + "/workspace/personas", + "Untitled Persona", + &personas, + &[ + "description", + "personality", + "scenario", + "backstory", + "appearance", + "firstMessage", + "greeting", + "notes", + ], + )?; + + let persona_groups = list_storage_or_empty(state, "persona-groups")?; + add_flat_collection( + &mut seed, + &mut allocator, + "persona-groups", + "/workspace/persona-groups", + "Untitled Persona Group", + &persona_groups, + &["description", "notes"], + )?; + + add_lorebooks_to_workspace(state, &mut seed, &mut allocator)?; + add_prompts_to_workspace(state, &mut seed, &mut allocator)?; + + add_workspace_index( + &mut seed, + &[ + ("characters", characters.len()), + ("character-groups", character_groups.len()), + ("personas", personas.len()), + ("persona-groups", persona_groups.len()), + ( + "lorebooks", + list_storage_or_empty(state, "lorebooks")?.len(), + ), + ("prompts", list_storage_or_empty(state, "prompts")?.len()), + ], + ); + + Ok(seed) +} + +fn add_flat_collection( + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, + entity: &str, + root: &str, + fallback_label: &str, + records: &[Value], + text_fields: &[&str], +) -> AppResult<()> { + let mut index = Vec::new(); + for record in sorted_records(records) { + let Some(id) = record_id(record) else { + continue; + }; + let label = record_label_for_entity(entity, record, fallback_label); + let folder = allocator.child(root, &label, fallback_label); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder(seed, entity, id, &folder, record, text_fields)?; + } + add_unbound_file( + seed, + format!("{root}/index.md"), + collection_index_title(entity, index), + ); + Ok(()) +} + +fn add_lorebooks_to_workspace( + state: &AppState, + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, +) -> AppResult<()> { + let lorebooks = list_storage_or_empty(state, "lorebooks")?; + let entries = list_storage_or_empty(state, "lorebook-entries")?; + let mut entries_by_lorebook: BTreeMap> = BTreeMap::new(); + for entry in entries { + if let Some(lorebook_id) = str_field(&entry, "lorebookId") { + entries_by_lorebook + .entry(lorebook_id.to_string()) + .or_default() + .push(entry); + } + } + let mut index = Vec::new(); + for lorebook in sorted_records(&lorebooks) { + let Some(id) = record_id(lorebook) else { + continue; + }; + let label = record_label_for_entity("lorebooks", lorebook, "Untitled Lorebook"); + let folder = allocator.child("/workspace/lorebooks", &label, "Untitled Lorebook"); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "lorebooks", + id, + &folder, + lorebook, + &["description", "content", "notes"], + )?; + let entry_root = format!("{folder}/entries"); + let mut entry_index = Vec::new(); + for entry in sorted_records( + entries_by_lorebook + .get(id) + .map(Vec::as_slice) + .unwrap_or(&[]), + ) { + let Some(entry_id) = record_id(entry) else { + continue; + }; + let entry_label = lorebook_entry_label(entry); + let entry_folder = allocator.child(&entry_root, &entry_label, "Untitled Entry"); + entry_index.push(format!( + "- [{}]({})", + display_label(&entry_label), + entry_folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "lorebook-entries", + entry_id, + &entry_folder, + entry, + &["content", "comment", "description", "notes"], + )?; + if let Some(keys) = keys_text(entry) { + add_bound_file( + seed, + format!("{entry_folder}/keys.txt"), + keys, + "lorebook-entries", + entry_id, + "keys", + ); } - }), - ] + } + add_unbound_file( + seed, + format!("{entry_root}/index.md"), + collection_index_title("entries", entry_index), + ); + } + add_unbound_file( + seed, + "/workspace/lorebooks/index.md", + collection_index_title("lorebooks", index), + ); + Ok(()) +} + +fn add_prompts_to_workspace( + state: &AppState, + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, +) -> AppResult<()> { + let prompts = list_storage_or_empty(state, "prompts")?; + let sections = list_storage_or_empty(state, "prompt-sections")?; + let groups = list_storage_or_empty(state, "prompt-groups")?; + let variables = list_storage_or_empty(state, "prompt-variables")?; + let mut sections_by_preset = group_by_parent(sections, "presetId"); + let mut groups_by_preset = group_by_parent(groups, "presetId"); + let mut variables_by_preset = group_by_parent(variables, "presetId"); + let mut index = Vec::new(); + for prompt in sorted_records(&prompts) { + let Some(id) = record_id(prompt) else { + continue; + }; + let label = record_label_for_entity("prompts", prompt, "Untitled Prompt"); + let folder = allocator.child("/workspace/prompts", &label, "Untitled Prompt"); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "prompts", + id, + &folder, + prompt, + &["description", "prompt", "systemPrompt", "notes"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "sections", + "prompt-sections", + sections_by_preset.remove(id).unwrap_or_default(), + &["prompt", "content", "text", "description"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "groups", + "prompt-groups", + groups_by_preset.remove(id).unwrap_or_default(), + &["description", "notes"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "variables", + "prompt-variables", + variables_by_preset.remove(id).unwrap_or_default(), + &["value", "content", "text", "description"], + )?; + } + add_unbound_file( + seed, + "/workspace/prompts/index.md", + collection_index_title("prompts", index), + ); + Ok(()) +} + +fn add_nested_prompt_records( + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, + prompt_folder: &str, + folder_name: &str, + entity: &str, + records: Vec, + text_fields: &[&str], +) -> AppResult<()> { + let root = format!("{prompt_folder}/{folder_name}"); + let mut index = Vec::new(); + for record in sorted_records(&records) { + let Some(id) = record_id(record) else { + continue; + }; + let label = record_label_for_entity( + entity, + record, + &format!("Untitled {}", singular_title(folder_name)), + ); + let folder = allocator.child( + &root, + &label, + &format!("Untitled {}", singular_title(folder_name)), + ); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder(seed, entity, id, &folder, record, text_fields)?; + } + add_unbound_file( + seed, + format!("{root}/index.md"), + collection_index_title(folder_name, index), + ); + Ok(()) +} + +fn add_record_folder( + seed: &mut MariWorkspaceSeed, + entity: &str, + id: &str, + folder: &str, + record: &Value, + text_fields: &[&str], +) -> AppResult<()> { + for field in text_fields { + if let Some(text) = + string_field_path_owned(record, field).filter(|value| !value.trim().is_empty()) + { + add_bound_file( + seed, + format!("{folder}/{}.md", field_file_name(field)), + text, + entity, + id, + field, + ); + } + } + let metadata = metadata_without_fields(record, text_fields); + let content = serde_json::to_string_pretty(&metadata) + .map_err(|error| AppError::new("mari_workspace_serialize_failed", error.to_string()))?; + add_bound_file( + seed, + format!("{folder}/metadata.json"), + content, + entity, + id, + "metadata", + ); + Ok(()) } -fn run_virtual_bash(state: &AppState, command: &str) -> Value { - match run_virtual_bash_result(state, command) { - Ok(stdout) => json!({ "stdout": stdout, "stderr": "", "exitCode": 0 }), - Err(error) => json!({ "stdout": "", "stderr": error.to_string(), "exitCode": 1 }), +fn add_workspace_index(seed: &mut MariWorkspaceSeed, counts: &[(&str, usize)]) { + let mut lines = vec![ + "# Marinara Workspace".to_string(), + String::new(), + "This virtual workspace contains your editable Marinara creative library.".to_string(), + "Internal storage IDs are hidden from paths; Professor Mari should use the folders below." + .to_string(), + String::new(), + ]; + for (name, count) in counts { + lines.push(format!("- [{name}]({name}/index.md): {count} record(s)")); } + add_unbound_file(seed, "/workspace/index.md", lines.join("\n")); +} + +fn add_unbound_file( + seed: &mut MariWorkspaceSeed, + path: impl Into, + content: impl Into, +) { + seed.files.push(MariWorkspaceFileRecord { + path: path.into(), + content: content.into(), + }); +} + +fn add_bound_file( + seed: &mut MariWorkspaceSeed, + path: String, + content: String, + entity: &str, + id: &str, + field: &str, +) { + let binding = MariWorkspaceBinding { + entity: entity.to_string(), + id: id.to_string(), + field: Some(field.to_string()), + }; + seed.bindings.insert(path.clone(), binding.clone()); + seed.files.push(MariWorkspaceFileRecord { path, content }); +} + +fn list_storage_or_empty(state: &AppState, entity: &str) -> AppResult> { + state.storage.list(entity).map_err(|error| { + AppError::new( + "mari_workspace_load_failed", + format!("Could not load {entity}: {error}"), + ) + }) +} + +fn sorted_records(records: &[Value]) -> Vec<&Value> { + let mut out = records.iter().collect::>(); + out.sort_by(|a, b| sort_key(a).cmp(&sort_key(b))); + out +} + +fn sort_key(record: &Value) -> String { + format!( + "{:012}|{}", + numeric_sort_field(record), + record_label(record, "Untitled").to_ascii_lowercase() + ) +} + +fn numeric_sort_field(record: &Value) -> i64 { + ["sortOrder", "order", "position", "createdAt"] + .iter() + .find_map(|field| record.get(*field).and_then(Value::as_i64)) + .unwrap_or(0) } -fn run_virtual_bash_result(state: &AppState, command: &str) -> AppResult { - reject_unsafe_shell(command)?; - let mut input: Option = None; - for segment in split_pipes(command)? { - input = Some(run_virtual_bash_segment(state, segment.trim(), input)?); +fn group_by_parent(records: Vec, parent_field: &str) -> BTreeMap> { + let mut grouped = BTreeMap::new(); + for record in records { + if let Some(parent_id) = str_field(&record, parent_field) { + grouped + .entry(parent_id.to_string()) + .or_insert_with(Vec::new) + .push(record); + } } - Ok(input.unwrap_or_default()) + grouped +} + +fn record_id(record: &Value) -> Option<&str> { + str_field(record, "id") } -fn run_virtual_bash_segment(state: &AppState, segment: &str, input: Option) -> AppResult { - let tokens = shell_words(segment)?; - let Some(cmd) = tokens.first().map(String::as_str) else { - return Ok(input.unwrap_or_default()); +fn record_label(record: &Value, fallback: &str) -> String { + record_label_for_entity("", record, fallback) +} + +fn record_label_for_entity(entity: &str, record: &Value, fallback: &str) -> String { + let candidates: &[&str] = match entity { + "characters" => &["data.name", "name", "title"], + "personas" => &["name", "data.name", "title", "comment"], + "lorebooks" => &["name", "title"], + "lorebook-entries" => &["comment", "name"], + "prompts" => &["name", "title"], + "prompt-sections" => &["name", "title", "role", "type"], + "prompt-groups" => &["name", "label", "title"], + "prompt-variables" => &["name", "key", "label", "title"], + _ => &["data.name", "name", "title", "label", "comment", "key"], }; - match cmd { - "ls" => { - let path = tokens.get(1).map(String::as_str).unwrap_or("/"); - let value = mari_fs::ls(state, path)?; - Ok(format_ls_output(&value)) - } - "cat" | "read" => { - let path = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("cat requires a path"))?; - read_file_text(state, path) - } - "head" => { - let (n, path) = parse_n_and_path(&tokens, 10); - let text = match path { Some(path) => read_file_text(state, path)?, None => input.unwrap_or_default() }; - Ok(text.lines().take(n).collect::>().join("\n")) - } - "tail" => { - let (n, path) = parse_n_and_path(&tokens, 10); - let text = match path { Some(path) => read_file_text(state, path)?, None => input.unwrap_or_default() }; - let lines = text.lines().collect::>(); - let start = lines.len().saturating_sub(n); - Ok(lines[start..].join("\n")) - } - "grep" => run_grep(state, &tokens, input), - "find" => run_find(state, &tokens), - "rm" => { - let path = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("rm requires a path"))?; - let value = mari_fs::rm(state, path)?; - serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) - } - "cp" => { - let from = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("cp requires a source path"))?; - let to = tokens.get(2).map(String::as_str).ok_or_else(|| AppError::invalid_input("cp requires a destination path"))?; - let content = read_file_text(state, from)?; - let value = mari_fs::write(state, to, &content)?; - serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) - } - "mv" => { - let from = tokens.get(1).map(String::as_str).ok_or_else(|| AppError::invalid_input("mv requires a source path"))?; - let to = tokens.get(2).map(String::as_str).ok_or_else(|| AppError::invalid_input("mv requires a destination path"))?; - let content = read_file_text(state, from)?; - let written = mari_fs::write(state, to, &content)?; - let removed = mari_fs::rm(state, from)?; - serde_json::to_string_pretty(&json!({ "written": written, "removed": removed })).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) - } - "write" => run_write_command(state, segment), - other => Err(AppError::invalid_input(format!("Unsupported virtual bash command: {other}"))), - } -} - -fn read_file_text(state: &AppState, path: &str) -> AppResult { - let value = mari_fs::read(state, path, Some(1), Some(240))?; - Ok(value.get("content").and_then(Value::as_str).unwrap_or("").to_string()) -} - -fn run_write_command(state: &AppState, segment: &str) -> AppResult { - let rest = segment.trim_start_matches("write").trim(); - let mut split = rest.splitn(2, char::is_whitespace); - let path = split.next().filter(|value| !value.trim().is_empty()).ok_or_else(|| AppError::invalid_input("write requires a path"))?; - let content = split.next().unwrap_or("").trim(); - if content.is_empty() { - return Err(AppError::invalid_input("write requires JSON content after the path")); - } - let value = mari_fs::write(state, path, content)?; - serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_bash_serialize_failed", error.to_string())) -} - -fn run_grep(state: &AppState, tokens: &[String], input: Option) -> AppResult { - let mut case_sensitive = true; - let mut recursive = false; - let mut args = Vec::new(); - for token in tokens.iter().skip(1) { - match token.as_str() { - "-i" => case_sensitive = false, - "-R" | "-r" => recursive = true, - other => args.push(other.to_string()), - } - } - let pattern = args.first().ok_or_else(|| AppError::invalid_input("grep requires a pattern"))?; - let needle = if case_sensitive { pattern.clone() } else { pattern.to_ascii_lowercase() }; - if let Some(text) = input { - return Ok(text.lines().filter(|line| { - let haystack = if case_sensitive { (*line).to_string() } else { line.to_ascii_lowercase() }; - haystack.contains(&needle) - }).collect::>().join("\n")); - } - let path = args.get(1).map(String::as_str).unwrap_or("/"); - let files = if recursive { collect_paths(state, path, true, None, None)? } else { vec![path.to_string()] }; - let mut out = Vec::new(); - for file in files { - let Ok(text) = read_file_text(state, &file) else { continue; }; - for (index, line) in text.lines().enumerate() { - let haystack = if case_sensitive { line.to_string() } else { line.to_ascii_lowercase() }; - if haystack.contains(&needle) { - out.push(format!("{file}:{}:{line}", index + 1)); + first_non_empty_owned( + candidates + .iter() + .filter_map(|field| string_field_path_owned(record, field)), + ) + .unwrap_or_else(|| fallback.to_string()) +} + +fn lorebook_entry_label(record: &Value) -> String { + first_non_empty(&[ + str_field(record, "comment"), + str_field(record, "name"), + first_string_array_item(record.get("keys")).as_deref(), + str_field(record, "content").map(first_line), + ]) + .unwrap_or("Untitled Entry") + .to_string() +} + +fn first_non_empty<'a>(values: &[Option<&'a str>]) -> Option<&'a str> { + values + .iter() + .flatten() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) +} + +fn first_non_empty_owned(values: impl IntoIterator) -> Option { + values + .into_iter() + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value.get(field).and_then(Value::as_str) +} + +fn string_field_path_owned(value: &Value, field_path: &str) -> Option { + fn walk(current: &Value, parts: &[&str]) -> Option { + if parts.is_empty() { + return current.as_str().map(str::to_string); + } + if let Some(object) = current.as_object() { + return walk(object.get(parts[0])?, &parts[1..]); + } + if let Some(raw) = current.as_str() { + let parsed = serde_json::from_str::(raw).ok()?; + return walk(&parsed, parts); + } + None + } + let parts = field_path.split('.').collect::>(); + walk(value, &parts) +} + +fn display_label(label: &str) -> String { + const LIMIT: usize = 120; + let clean = label.replace(['\n', '\r'], " "); + if clean.chars().count() > LIMIT { + format!("{}…", clean.chars().take(LIMIT).collect::()) + } else { + clean + } +} + +fn first_line(value: &str) -> &str { + value.lines().next().unwrap_or(value).trim() +} + +fn first_string_array_item(value: Option<&Value>) -> Option { + string_array_items(value).into_iter().next() +} + +fn string_array_items(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + Some(Value::String(raw)) => serde_json::from_str::>(raw) + .unwrap_or_else(|_| raw.lines().map(str::to_string).collect()), + _ => Vec::new(), + } +} + +fn keys_text(record: &Value) -> Option { + let keys = string_array_items(record.get("keys").or_else(|| record.get("keywords"))); + (!keys.is_empty()).then(|| keys.join("\n")) +} + +fn metadata_without_fields(record: &Value, text_fields: &[&str]) -> Value { + let mut metadata = record.clone(); + expand_json_string_objects(&mut metadata); + remove_field_path(&mut metadata, "id"); + remove_field_path(&mut metadata, "createdAt"); + remove_field_path(&mut metadata, "updatedAt"); + for field in text_fields { + remove_field_path(&mut metadata, field); + } + sanitize_metadata_value(&mut metadata); + metadata +} + +fn expand_json_string_objects(value: &mut Value) { + match value { + Value::Object(object) => { + for child in object.values_mut() { + expand_json_string_objects(child); + } + } + Value::Array(items) => { + for item in items { + expand_json_string_objects(item); + } + } + Value::String(raw) => { + if let Ok(parsed @ (Value::Object(_) | Value::Array(_))) = + serde_json::from_str::(raw) + { + *value = parsed; + expand_json_string_objects(value); } } + _ => {} } - Ok(out.join("\n")) } -fn run_find(state: &AppState, tokens: &[String]) -> AppResult { - let root = tokens.get(1).map(String::as_str).unwrap_or("/"); - let mut name_pattern: Option = None; - let mut entry_type: Option = None; - let mut max_depth: Option = None; - let mut index = 2; - while index < tokens.len() { - match tokens[index].as_str() { - "-name" => { name_pattern = tokens.get(index + 1).cloned(); index += 2; } - "-type" => { entry_type = tokens.get(index + 1).cloned(); index += 2; } - "-maxdepth" => { max_depth = tokens.get(index + 1).and_then(|v| v.parse().ok()); index += 2; } - _ => index += 1, +fn remove_field_path(value: &mut Value, field_path: &str) { + let mut current = value; + let mut parts = field_path.split('.').peekable(); + while let Some(field) = parts.next() { + let Some(object) = current.as_object_mut() else { + return; + }; + if parts.peek().is_none() { + object.remove(field); + return; } + let Some(next) = object.get_mut(field) else { + return; + }; + current = next; } - Ok(collect_paths(state, root, entry_type.as_deref() != Some("d"), name_pattern.as_deref(), max_depth)?.join("\n")) } -fn collect_paths(state: &AppState, root: &str, files: bool, pattern: Option<&str>, max_depth: Option) -> AppResult> { - fn walk(state: &AppState, path: &str, depth: usize, files: bool, pattern: Option<&str>, max_depth: Option, out: &mut Vec) -> AppResult<()> { - if max_depth.is_some_and(|max| depth > max) { return Ok(()); } - let listing = mari_fs::ls(state, path)?; - for entry in listing.get("entries").and_then(Value::as_array).into_iter().flatten() { - let entry_path = entry.get("path").and_then(Value::as_str).unwrap_or(""); - let entry_name = entry.get("name").and_then(Value::as_str).unwrap_or(""); - let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("file"); - let matches = pattern.map(|pat| wildcard_match(pat, entry_name) || wildcard_match(pat, entry_path)).unwrap_or(true); - if ((files && entry_type == "file") || (!files && entry_type == "directory")) && matches { - out.push(entry_path.to_string()); +fn sanitize_metadata_value(value: &mut Value) { + match value { + Value::Object(object) => { + let keys = object.keys().cloned().collect::>(); + for key in keys { + if should_remove_metadata_key(&key) { + object.remove(&key); + } else if let Some(child) = object.get_mut(&key) { + sanitize_metadata_value(child); + } } - if entry_type == "directory" { - let _ = walk(state, entry_path, depth + 1, files, pattern, max_depth, out); + } + Value::Array(items) => { + for item in items.iter_mut().take(64) { + sanitize_metadata_value(item); + } + if items.len() > 64 { + items.truncate(64); + items.push(json!("[truncated metadata array]")); + } + } + Value::String(text) => { + if looks_like_base64_blob(text) { + *text = "[omitted binary/base64 data]".to_string(); + } else if text.chars().count() > MARI_METADATA_STRING_LIMIT { + *text = format!( + "{}\n[truncated metadata string]", + text.chars() + .take(MARI_METADATA_STRING_LIMIT) + .collect::() + ); } } - Ok(()) + _ => {} + } +} + +fn should_remove_metadata_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + lower.contains("avatar") + || lower.contains("image") + || lower.contains("base64") + || lower.contains("datauri") + || lower == "data_url" + || lower == "dataurl" +} + +fn looks_like_base64_blob(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.starts_with("data:image/") + || (trimmed.len() > 8_000 + && trimmed.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '\n' | '\r') + })) +} + +fn field_file_name(field: &str) -> String { + let mut out = String::new(); + for (index, ch) in field.chars().enumerate() { + if ch.is_ascii_uppercase() && index > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + sanitize_path_segment(&out) +} + +fn sanitize_path_segment(value: &str) -> String { + let mut out = value + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + ch if ch.is_control() => '_', + ch => ch, + }) + .collect::(); + while out.contains(" ") { + out = out.replace(" ", " "); + } + out = out.trim_matches(['.', ' ']).to_string(); + if out.is_empty() { + out = "Untitled".to_string(); + } + if out.chars().count() > 96 { + out = out.chars().take(96).collect(); } - let mut out = Vec::new(); - walk(state, root, 0, files, pattern, max_depth, &mut out)?; - Ok(out) + out } -fn parse_n_and_path(tokens: &[String], default_n: usize) -> (usize, Option<&str>) { - if tokens.get(1).map(String::as_str) == Some("-n") { - (tokens.get(2).and_then(|v| v.parse().ok()).unwrap_or(default_n), tokens.get(3).map(String::as_str)) +fn collection_index_title(name: &str, entries: Vec) -> String { + let mut lines = vec![format!("# {}", title_case(name)), String::new()]; + if entries.is_empty() { + lines.push("No records found.".to_string()); } else { - (default_n, tokens.get(1).map(String::as_str)) + lines.extend(entries); } + lines.join("\n") } -fn format_ls_output(value: &Value) -> String { - value.get("entries").and_then(Value::as_array).map(|entries| { - entries.iter().filter_map(|entry| entry.get("path").and_then(Value::as_str)).collect::>().join("\n") - }).unwrap_or_else(|| value.to_string()) +fn singular_title(name: &str) -> String { + title_case(name.trim_end_matches('s')) } -fn split_pipes(command: &str) -> AppResult> { - let mut parts = Vec::new(); - let mut start = 0; - let mut quote: Option = None; - for (index, ch) in command.char_indices() { - match ch { - '\'' | '"' if quote == Some(ch) => quote = None, - '\'' | '"' if quote.is_none() => quote = Some(ch), - '|' if quote.is_none() => { parts.push(command[start..index].trim()); start = index + 1; } - _ => {} - } - } - if quote.is_some() { return Err(AppError::invalid_input("Unclosed quote in virtual bash command")); } - parts.push(command[start..].trim()); - Ok(parts.into_iter().filter(|part| !part.is_empty()).collect()) -} - -fn shell_words(segment: &str) -> AppResult> { - let mut words = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - for ch in segment.chars() { - match ch { - '\'' | '"' if quote == Some(ch) => quote = None, - '\'' | '"' if quote.is_none() => quote = Some(ch), - ch if ch.is_whitespace() && quote.is_none() => { - if !current.is_empty() { words.push(std::mem::take(&mut current)); } +fn title_case(value: &str) -> String { + value + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +#[derive(Clone)] +struct MariShellSession { + fs: Arc, + bash: Arc>, + initial_files: Arc>>>, + manifest: Arc>, +} + +impl MariShellSession { + async fn new( + input: &MariPromptRequest, + workspace_seed: MariWorkspaceSeed, + ) -> AppResult> { + let fs = Arc::new(TrackingFs::new()); + fs.add_text_file("/workspace/system-prompt.md", MARI_SYSTEM_PROMPT); + fs.add_text_file("/workspace/README.md", PROF_MARI_WORKSPACE_README); + if let Some(persona) = build_persona_context(input.persona.as_ref()) { + fs.add_text_file("/workspace/active-persona.md", &persona); + } + for file in &workspace_seed.files { + fs.add_text_file(&file.path, &file.content); + } + for file in &input.workspace_files { + let path = resolve_virtual_path(&file.path); + fs.add_text_file(&path, &file.content); + } + for attachment in &input.attachments { + if !attachment.r#type.to_ascii_lowercase().starts_with("image/") { + let safe_name = sanitize_filename(&attachment.name); + fs.add_text_file( + format!("/workspace/attachments/{safe_name}").as_str(), + &attachment.content, + ); + } + } + let bash = Bash::builder() + .fs(fs.clone()) + .cwd("/workspace") + .env("HOME", "/workspace") + .env("USER", "prof-mari") + .build(); + let session = Arc::new(Self { + fs, + bash: Arc::new(Mutex::new(bash)), + initial_files: Arc::new(RwLock::new(BTreeMap::new())), + manifest: Arc::new(workspace_seed.bindings), + }); + let initial = session.snapshot_review_files().await?; + *session.initial_files.write().unwrap() = initial; + Ok(session) + } + + async fn exec_bash(&self, command: &str) -> AppResult { + let mut bash = self.bash.lock().await; + let output = bash + .exec(command) + .await + .map_err(|error| AppError::new("mari_bash_failed", error.to_string()))?; + drop(bash); + Ok(json!({ + "stdout": truncate_tool_text(&output.stdout), + "stderr": truncate_tool_text(&output.stderr), + "exitCode": output.exit_code, + "pendingChanges": self.pending_changes().await?, + })) + } + + async fn read_text(&self, path: &str) -> AppResult { + let path = resolve_virtual_path(path); + let bytes = self + .fs + .read_file(Path::new(&path)) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))?; + Ok(String::from_utf8_lossy(&bytes).to_string()) + } + + async fn write_text(&self, path: &str, content: &str) -> AppResult { + let path = resolve_virtual_path(path); + ensure_parent_dirs(&self.fs, Path::new(&path)).await?; + self.fs + .write_file(Path::new(&path), content.as_bytes()) + .await + .map_err(|error| AppError::new("mari_write_failed", error.to_string()))?; + Ok(json!({ "path": path, "pendingChanges": self.pending_changes().await? })) + } + + async fn edit_text(&self, path: &str, old_text: &str, new_text: &str) -> AppResult { + let path = resolve_virtual_path(path); + let current = self.read_text(&path).await?; + let matches = current.matches(old_text).count(); + if matches != 1 { + return Err(AppError::invalid_input(format!( + "edit expected oldText to match exactly once, found {matches} matches" + ))); + } + let updated = current.replacen(old_text, new_text, 1); + self.write_text(&path, &updated).await + } + + async fn pending_changes(&self) -> AppResult> { + let current = self.snapshot_review_files().await?; + let initial = self.initial_files.read().unwrap().clone(); + Ok(diff_file_maps(&initial, ¤t)) + } + + fn manifest_summary(&self) -> Value { + let mut by_entity: BTreeMap<&str, usize> = BTreeMap::new(); + let mut text_field_bindings = 0usize; + for binding in self.manifest.values() { + *by_entity.entry(binding.entity.as_str()).or_default() += 1; + if binding + .field + .as_deref() + .is_some_and(|field| field != "metadata") + { + text_field_bindings += 1; } - _ => current.push(ch), + let _ = binding.id.as_str(); } + json!({ + "boundFiles": self.manifest.len(), + "textFieldBindings": text_field_bindings, + "byEntity": by_entity, + }) + } + + async fn snapshot_review_files(&self) -> AppResult>> { + let mut files = BTreeMap::new(); + collect_files_recursive(&self.fs, Path::new("/workspace"), &mut files).await?; + Ok(files) } - if quote.is_some() { return Err(AppError::invalid_input("Unclosed quote in virtual bash command")); } - if !current.is_empty() { words.push(current); } - Ok(words) } -fn reject_unsafe_shell(command: &str) -> AppResult<()> { - let blocked = [";", "&&", "||", "`", "$(", ">", "<", " sudo ", " curl ", " wget ", " powershell", " cmd", " python", " node", " chmod", " chown"]; - let padded = format!(" {} ", command.to_ascii_lowercase()); - if let Some(token) = blocked.iter().find(|token| padded.contains(**token)) { - return Err(AppError::invalid_input(format!("Unsupported or unsafe virtual bash syntax: {token}"))); +const PROF_MARI_WORKSPACE_README: &str = "# Prof Mari virtual workspace\n\nThis is an isolated bash workspace populated from the user's Marinara creative library. Start at `/workspace/index.md`, then inspect folders such as `characters/`, `personas/`, `lorebooks/`, and `prompts/`. Paths are descriptive and duplicate-safe; Marinara tracks hidden storage IDs internally. Changes remain staged for user review.\n"; + +struct TrackingFs { + inner: InMemoryFs, +} + +impl fmt::Debug for TrackingFs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TrackingFs").finish() } - Ok(()) } -fn wildcard_match(pattern: &str, value: &str) -> bool { - let pattern = pattern.to_ascii_lowercase(); - let value = value.to_ascii_lowercase(); - if pattern == "*" { return true; } - let parts = pattern.split('*').collect::>(); - if parts.len() == 1 { return value.contains(&pattern); } - let mut remainder = value.as_str(); - for (index, part) in parts.iter().filter(|part| !part.is_empty()).enumerate() { - if let Some(pos) = remainder.find(part) { - if index == 0 && !pattern.starts_with('*') && pos != 0 { return false; } - remainder = &remainder[pos + part.len()..]; - } else { return false; } - } - pattern.ends_with('*') || parts.last().is_some_and(|last| remainder.is_empty() || last.is_empty()) -} - -fn normalize_tool_call_for_chat_history(call: &Value) -> Value { - let name = tool_call_name(call); - let arguments = call - .get("function") - .and_then(|function| function.get("arguments")) - .or_else(|| call.get("arguments")) - .and_then(Value::as_str) - .unwrap_or("{}") - .to_string(); - json!({ - "id": tool_call_id(call).unwrap_or_else(|| "mari_tool_call".to_string()), - "type": "function", - "function": { - "name": name, - "arguments": arguments, +impl TrackingFs { + fn new() -> Self { + Self { + inner: InMemoryFs::new(), } - }) + } + + fn add_text_file(&self, path: &str, content: &str) { + self.inner.add_file(path, content.as_bytes(), 0o644); + } } -fn tool_call_name(call: &Value) -> String { - call.get("function") - .and_then(|function| function.get("name")) - .or_else(|| call.get("name")) - .and_then(Value::as_str) - .unwrap_or("") - .to_string() +#[bashkit_async_trait] +impl FileSystemExt for TrackingFs { + fn usage(&self) -> bashkit::FsUsage { + self.inner.usage() + } + + fn limits(&self) -> bashkit::FsLimits { + self.inner.limits() + } + + fn vfs_snapshot(&self) -> Option { + self.inner.vfs_snapshot() + } + + fn vfs_restore(&self, snapshot: &bashkit::VfsSnapshot) -> bashkit::Result<()> { + self.inner.vfs_restore(snapshot) + } +} + +#[bashkit_async_trait] +impl FileSystem for TrackingFs { + async fn read_file(&self, path: &Path) -> bashkit::Result> { + self.inner.read_file(path).await + } + async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> { + self.inner.write_file(path, content).await + } + async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> { + self.inner.append_file(path, content).await + } + async fn mkdir(&self, path: &Path, recursive: bool) -> bashkit::Result<()> { + self.inner.mkdir(path, recursive).await + } + async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> { + self.inner.remove(path, recursive).await + } + async fn stat(&self, path: &Path) -> bashkit::Result { + self.inner.stat(path).await + } + async fn read_dir(&self, path: &Path) -> bashkit::Result> { + self.inner.read_dir(path).await + } + async fn exists(&self, path: &Path) -> bashkit::Result { + self.inner.exists(path).await + } + async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> { + self.inner.rename(from, to).await + } + async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> { + self.inner.copy(from, to).await + } + async fn symlink(&self, target: &Path, link: &Path) -> bashkit::Result<()> { + self.inner.symlink(target, link).await + } + async fn read_link(&self, path: &Path) -> bashkit::Result { + self.inner.read_link(path).await + } + async fn chmod(&self, path: &Path, mode: u32) -> bashkit::Result<()> { + self.inner.chmod(path, mode).await + } + async fn set_modified_time(&self, path: &Path, time: SystemTime) -> bashkit::Result<()> { + self.inner.set_modified_time(path, time).await + } +} + +#[derive(Debug, Clone, Copy)] +enum PiToolKind { + Read, + Bash, + Edit, + Write, } -fn tool_call_arguments(call: &Value) -> Value { - let raw = call - .get("function") - .and_then(|function| function.get("arguments")) - .or_else(|| call.get("arguments")); - match raw { - Some(Value::String(text)) => serde_json::from_str(text).unwrap_or_else(|_| json!({})), - Some(Value::Object(_)) => raw.cloned().unwrap_or_else(|| json!({})), - _ => json!({}), +#[derive(Clone)] +struct PiLikeTool { + kind: PiToolKind, + session: Arc, +} + +impl fmt::Debug for PiLikeTool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PiLikeTool") + .field("kind", &self.kind) + .finish() } } -fn tool_call_id(call: &Value) -> Option { - call.get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .or_else(|| call.get("call_id").and_then(Value::as_str)) - .map(str::to_string) +#[async_trait] +impl ToolRuntime for PiLikeTool { + async fn execute(&self, args: Value) -> Result { + let result = match self.kind { + PiToolKind::Read => self.tool_read(args).await, + PiToolKind::Bash => self.tool_bash(args).await, + PiToolKind::Edit => self.tool_edit(args).await, + PiToolKind::Write => self.tool_write(args).await, + }; + result.map_err(tool_runtime_error) + } +} + +impl ToolT for PiLikeTool { + fn name(&self) -> &str { + match self.kind { + PiToolKind::Read => "read", + PiToolKind::Bash => "bash", + PiToolKind::Edit => "edit", + PiToolKind::Write => "write", + } + } + + fn description(&self) -> &str { + match self.kind { + PiToolKind::Read => "Read a text file from the virtual workspace. Supports optional 1-indexed offset and line limit.", + PiToolKind::Bash => "Execute bash commands in the isolated virtual workspace. File changes are staged and returned as pendingChanges.", + PiToolKind::Edit => "Edit a text file using exact text replacement. oldText must match exactly once.", + PiToolKind::Write => "Create or overwrite a text file in the virtual workspace.", + } + } + + fn args_schema(&self) -> Value { + match self.kind { + PiToolKind::Read => { + json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer","minimum":1},"limit":{"type":"integer","minimum":1}},"required":["path"]}) + } + PiToolKind::Bash => { + json!({"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}) + } + PiToolKind::Edit => { + json!({"type":"object","properties":{"path":{"type":"string"},"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["path","oldText","newText"]}) + } + PiToolKind::Write => { + json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}) + } + } + } +} + +impl PiLikeTool { + async fn tool_read(&self, args: Value) -> AppResult { + let path = required_str(&args, "path")?; + let offset = args + .get("offset") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1) as usize; + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map(|v| v.max(1) as usize); + let content = self.session.read_text(path).await?; + let lines: Vec<&str> = content.lines().collect(); + let selected = lines + .iter() + .skip(offset - 1) + .take(limit.unwrap_or(usize::MAX)) + .copied() + .collect::>() + .join("\n"); + Ok( + json!({"path": resolve_virtual_path(path), "content": truncate_tool_text(&selected), "totalLines": lines.len()}), + ) + } + + async fn tool_bash(&self, args: Value) -> AppResult { + self.session + .exec_bash(required_str(&args, "command")?) + .await + } + + async fn tool_edit(&self, args: Value) -> AppResult { + self.session + .edit_text( + required_str(&args, "path")?, + required_str(&args, "oldText")?, + required_str(&args, "newText")?, + ) + .await + } + + async fn tool_write(&self, args: Value) -> AppResult { + self.session + .write_text( + required_str(&args, "path")?, + required_str(&args, "content")?, + ) + .await + } } -fn read_only_mari_action_contract() -> Value { - json!({ - "type": "none", - "capability": "read_only", - "reason": "Professor Mari v1 can inspect the creative library but cannot create or edit records.", +fn build_pi_like_tools(session: Arc) -> Vec> { + [ + PiToolKind::Read, + PiToolKind::Bash, + PiToolKind::Edit, + PiToolKind::Write, + ] + .into_iter() + .map(|kind| { + Arc::new(PiLikeTool { + kind, + session: session.clone(), + }) as Arc }) + .collect() } -fn build_system_prompt(persona: Option<&MariPersonaContext>) -> String { - let mut parts = vec![ - "You are Professor Mari, a standalone assistant inside Marinara Engine.".to_string(), - "You have a virtual workspace with two tools: bash and edit.".to_string(), - "Use bash for safe virtual shell commands: ls, cat/read, head, tail, grep, find, write, rm, cp, and mv. Use edit for exact structured oldText/newText replacements. Treat paths as virtual Marinara workspace paths, not host filesystem paths.".to_string(), - "The workspace exposes creative-library records only: characters, character groups, personas, persona groups, lorebooks with nested entries, and prompt presets with nested sections/groups/variables.".to_string(), - "You may edit and write records in this workspace. edit uses exact oldText/newText replacements against read output. write writes complete JSON content to a writable virtual file and stores it in Marinara.".to_string(), - "You cannot delete records, run shell commands, access network resources, access chats/messages/memories, or view secrets/connections/API keys.".to_string(), - "If the user asks about library data, inspect it with ls/read before answering. Do not invent data. Answer plainly after using any needed tools.".to_string(), - ]; - if let Some(persona) = persona { - let persona_text = [ - ("Name", persona.name.as_deref()), - ("Comment", persona.comment.as_deref()), - ("Description", persona.description.as_deref()), - ("Personality", persona.personality.as_deref()), - ("Scenario", persona.scenario.as_deref()), - ("Backstory", persona.backstory.as_deref()), - ("Appearance", persona.appearance.as_deref()), - ] - .into_iter() - .filter_map(|(label, value)| { - let value = value?.trim(); - (!value.is_empty()).then(|| format!("{label}: {value}")) - }) - .collect::>() - .join("\n"); - if !persona_text.is_empty() { - parts.push(format!("The user's selected persona is:\n{persona_text}")); +fn required_str<'a>(value: &'a Value, key: &str) -> AppResult<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AppError::invalid_input(format!("missing required string field `{key}`"))) +} + +fn tool_runtime_error(error: AppError) -> ToolCallError { + ToolCallError::RuntimeError(Box::new(std::io::Error::other(error.to_string()))) +} + +fn resolve_virtual_path(path: &str) -> String { + let trimmed = path.trim(); + let raw = if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/workspace/{trimmed}") + }; + normalize_virtual_path(&raw) +} + +fn normalize_virtual_path(path: &str) -> String { + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + other => parts.push(other), } } - parts.join("\n\n") + format!("/{}", parts.join("/")) } -fn build_task_prompt(input: &MariPromptRequest) -> String { - let mut sections = Vec::new(); - let history = input - .messages - .iter() - .rev() - .take(16) - .collect::>() - .into_iter() - .rev() - .filter_map(|message| { - let content = message.content.trim(); - (!content.is_empty()).then(|| format!("{}: {content}", message.role)) +fn sanitize_filename(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } }) - .collect::>() - .join("\n"); - if !history.is_empty() { - sections.push(format!("Conversation history:\n{history}")); + .collect(); + if cleaned.is_empty() { + "attachment.txt".to_string() + } else { + cleaned } - if !input.attachments.is_empty() { - let attachments = input - .attachments - .iter() - .map(|attachment| { - format!( - "File: {}\nType: {}\nSize: {}\nContent:\n{}", - attachment.name, attachment.r#type, attachment.size, attachment.content - ) - }) - .collect::>() - .join("\n\n---\n\n"); - sections.push(format!( - "Attached files for the latest user turn:\n{attachments}" - )); +} + +async fn ensure_parent_dirs(fs: &TrackingFs, path: &Path) -> AppResult<()> { + if let Some(parent) = path.parent() { + fs.mkdir(parent, true) + .await + .map_err(|error| AppError::new("mari_mkdir_failed", error.to_string()))?; } - sections.push(format!( - "Latest user message:\n{}", - input.user_message.trim() - )); - sections.join("\n\n") + Ok(()) } -fn ensure_connection_supports_native_tools(connection: &marinara_llm::LlmConnection) -> AppResult<()> { - match connection.provider.as_str() { - "openai" | "openai_chatgpt" | "openrouter" | "custom" | "xai" | "mistral" | "cohere" | "nanogpt" => Ok(()), - provider => Err(AppError::invalid_input(format!( - "Professor Mari requires a connection with native tool-call support. The selected provider '{provider}' is not enabled for native tools in Marinara's Rust LLM transport yet. Use an OpenAI-compatible, OpenRouter, OpenAI, xAI, Mistral, Cohere, NanoGPT, or custom OpenAI-compatible connection with a tool-capable chat model." - ))), +async fn collect_files_recursive( + fs: &TrackingFs, + path: &Path, + files: &mut BTreeMap>, +) -> AppResult<()> { + if !fs + .exists(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))? + { + return Ok(()); + } + let meta = fs + .stat(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))?; + if meta.file_type == FileType::File { + let content = fs + .read_file(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))?; + files.insert(path.to_string_lossy().to_string(), content); + return Ok(()); } + if meta.file_type == FileType::Directory { + for entry in fs + .read_dir(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))? + { + let child = path.join(entry.name); + Box::pin(collect_files_recursive(fs, &child, files)).await?; + } + } + Ok(()) +} + +fn diff_file_maps( + before: &BTreeMap>, + after: &BTreeMap>, +) -> Vec { + let paths = before + .keys() + .chain(after.keys()) + .cloned() + .collect::>(); + paths.into_iter().filter_map(|path| { + match (before.get(&path), after.get(&path)) { + (None, Some(after)) => Some(json!({"op":"create", "path": path, "after": text_preview(after)})), + (Some(before), None) => Some(json!({"op":"delete", "path": path, "before": text_preview(before)})), + (Some(before), Some(after)) if before != after => Some(json!({"op":"modify", "path": path, "before": text_preview(before), "after": text_preview(after)})), + _ => None, + } + }).collect() +} + +fn text_preview(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + truncate_tool_text(&text) } -fn tool_call_error_message(message: &str) -> String { - if message.contains("Provider response did not contain assistant text or tool calls") { - return "The selected model/provider did not return a native tool call or assistant message. Professor Mari's read-library path requires native tool calling; choose a tool-capable chat model on the selected connection.".to_string(); +fn truncate_tool_text(text: &str) -> String { + if text.chars().count() > MARI_TOOL_TEXT_LIMIT { + format!( + "{}\n[truncated after {} characters]", + text.chars().take(MARI_TOOL_TEXT_LIMIT).collect::(), + MARI_TOOL_TEXT_LIMIT + ) + } else { + text.to_string() } - message.to_string() +} + +async fn staged_mari_action_contract(session: &MariShellSession) -> AppResult { + let changes = session.pending_changes().await?; + Ok(json!({ + "type": if changes.is_empty() { "none" } else { "staged_file_changes" }, + "capability": "bashkit_virtual_workspace", + "changes": changes, + "workspaceManifest": session.manifest_summary(), + "approvalRequired": !changes.is_empty(), + })) } fn format_app_error_for_debug(error: &AppError) -> String { let mut message = error.to_string(); if let Some(details) = &error.details { - let details = serde_json::to_string_pretty(details) - .unwrap_or_else(|serialize_error| format!("Could not serialize error details: {serialize_error}")); + let details = serde_json::to_string_pretty(details).unwrap_or_else(|serialize_error| { + format!("Could not serialize error details: {serialize_error}") + }); message.push_str("\nProvider debug details:\n"); message.push_str(&details.chars().take(12_000).collect::()); } diff --git a/src-tauri/src/commands/storage/mari_fs.rs b/src-tauri/src/commands/storage/mari_fs.rs deleted file mode 100644 index a23affae7..000000000 --- a/src-tauri/src/commands/storage/mari_fs.rs +++ /dev/null @@ -1,744 +0,0 @@ -use crate::state::AppState; -use marinara_core::{AppError, AppResult}; -use serde_json::{json, Map, Value}; - -const MAX_READ_LINES: usize = 240; -const MAX_READ_BYTES: usize = 48 * 1024; - -const ROOT_MOUNTS: &[MariMount] = &[ - MariMount { dir: "characters", collection: "characters", label: "Characters", prefix: "character" }, - MariMount { dir: "character-groups", collection: "character-groups", label: "Character groups", prefix: "character-group" }, - MariMount { dir: "personas", collection: "personas", label: "Personas", prefix: "persona" }, - MariMount { dir: "persona-groups", collection: "persona-groups", label: "Persona groups", prefix: "persona-group" }, - MariMount { dir: "lorebooks", collection: "lorebooks", label: "Lorebooks", prefix: "lorebook" }, - MariMount { dir: "prompts", collection: "prompts", label: "Prompt presets", prefix: "prompt" }, -]; - -const PROMPT_CHILD_MOUNTS: &[MariMount] = &[ - MariMount { dir: "sections", collection: "prompt-sections", label: "Prompt sections", prefix: "section" }, - MariMount { dir: "groups", collection: "prompt-groups", label: "Prompt groups", prefix: "group" }, - MariMount { dir: "variables", collection: "prompt-variables", label: "Prompt variables", prefix: "variable" }, -]; - -const LOREBOOK_ENTRY_MOUNT: MariMount = MariMount { - dir: "entries", - collection: "lorebook-entries", - label: "Lorebook entries", - prefix: "entry", -}; - -struct MariMount { - dir: &'static str, - collection: &'static str, - label: &'static str, - prefix: &'static str, -} - -#[derive(Clone)] -struct MariRecordRef { - ordinal: usize, - id: String, - label: String, - path: String, -} - -pub(crate) fn ls(state: &AppState, path: &str) -> AppResult { - let normalized = normalize_path(path)?; - let parts = parts(&normalized); - match parts.as_slice() { - [] => Ok(json!({ - "path": "/", - "entries": [ - { "name": "help.md", "type": "file", "path": "/help.md" }, - { "name": "library", "type": "directory", "path": "/library" }, - { "name": "schema", "type": "directory", "path": "/schema" } - ] - })), - ["library"] => Ok(json!({ - "path": "/library", - "entries": ROOT_MOUNTS.iter().map(|mount| json!({ - "name": mount.dir, - "type": "directory", - "path": format!("/library/{}", mount.dir), - "label": mount.label, - "collection": mount.collection, - })).collect::>() - })), - ["library", "prompts"] => ls_parent_collection(state, root_mount("prompts")?, true), - ["library", "prompts", prompt_dir] => ls_prompt_dir(state, prompt_dir), - ["library", "prompts", prompt_dir, child_dir] => ls_prompt_child_dir(state, prompt_dir, child_dir), - ["library", "lorebooks"] => ls_parent_collection(state, root_mount("lorebooks")?, true), - ["library", "lorebooks", lorebook_dir] => ls_lorebook_dir(state, lorebook_dir), - ["library", "lorebooks", lorebook_dir, "entries"] => ls_lorebook_entries_dir(state, lorebook_dir), - ["library", dir] => { - let mount = root_mount(dir)?; - ls_flat_collection(state, mount) - } - ["schema"] => Ok(json!({ - "path": "/schema", - "entries": schema_entries(), - })), - _ => Err(AppError::not_found(format!("No such directory in Professor Mari workspace: {normalized}"))), - } -} - -pub(crate) fn read(state: &AppState, path: &str, offset: Option, limit: Option) -> AppResult { - let normalized = normalize_path(path)?; - let content = read_content(state, &normalized)?; - let total_lines = content.lines().count().max(1); - let offset = offset.unwrap_or(1).max(1); - let limit = limit.unwrap_or(MAX_READ_LINES).min(MAX_READ_LINES).max(1); - let mut selected = content.lines().skip(offset.saturating_sub(1)).take(limit).collect::>().join("\n"); - let mut truncated = offset.saturating_sub(1) + limit < total_lines; - if selected.len() > MAX_READ_BYTES { - selected.truncate(MAX_READ_BYTES); - truncated = true; - } - Ok(json!({ - "path": normalized, - "offset": offset, - "limit": limit, - "totalLines": total_lines, - "truncated": truncated, - "content": selected, - })) -} - -pub(crate) fn write(state: &AppState, path: &str, content: &str) -> AppResult { - let normalized = normalize_path(path)?; - let parsed = serde_json::from_str::(content) - .map_err(|error| AppError::invalid_input(format!("write content must be valid JSON for {normalized}: {error}")))?; - write_json_content(state, &normalized, parsed) -} - -pub(crate) fn edit(state: &AppState, path: &str, edits: &[(String, String)]) -> AppResult { - if edits.is_empty() { - return Err(AppError::invalid_input("edit requires at least one replacement")); - } - let normalized = normalize_path(path)?; - let mut content = read_content(state, &normalized)?; - for (old_text, new_text) in edits { - if old_text.is_empty() { - return Err(AppError::invalid_input("edit oldText must not be empty")); - } - let count = content.matches(old_text).count(); - if count != 1 { - return Err(AppError::invalid_input(format!( - "edit oldText must match exactly once in {normalized}; matched {count} times" - ))); - } - content = content.replacen(old_text, new_text, 1); - } - write(state, &normalized, &content) -} - -pub(crate) fn rm(state: &AppState, path: &str) -> AppResult { - let normalized = normalize_path(path)?; - let parts = parts(&normalized); - let (collection, id) = match parts.as_slice() { - ["library", "prompts", prompt_dir] | ["library", "prompts", prompt_dir, "preset.json"] => { - let prompt = prompt_ref(state, prompt_dir)?; - ("prompts", prompt.id) - } - ["library", "prompts", prompt_dir, child_dir, filename] => { - let prompt = prompt_ref(state, prompt_dir)?; - let mount = prompt_child_mount(child_dir)?; - let id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename)?; - (mount.collection, id) - } - ["library", "lorebooks", lorebook_dir] | ["library", "lorebooks", lorebook_dir, "book.json"] => { - let lorebook = lorebook_ref(state, lorebook_dir)?; - ("lorebooks", lorebook.id) - } - ["library", "lorebooks", lorebook_dir, "entries", filename] => { - let lorebook = lorebook_ref(state, lorebook_dir)?; - let id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename)?; - ("lorebook-entries", id) - } - ["library", dir, filename] => { - let mount = root_mount(dir)?; - if mount.collection == "prompts" || mount.collection == "lorebooks" { - return Err(AppError::invalid_input("Delete prompt and lorebook parents by their directory path or parent file path")); - } - let id = resolve_record_id_from_file_name(state, mount, None, filename)?; - (mount.collection, id) - } - _ => return Err(AppError::invalid_input(format!("Path is not deletable in Professor Mari workspace: {normalized}"))), - }; - let deleted = state.storage.delete(collection, &id)?; - Ok(json!({ "path": normalized, "collection": collection, "id": id, "deleted": deleted })) -} - -fn write_json_content(state: &AppState, normalized: &str, content: Value) -> AppResult { - let parts = parts(normalized); - match parts.as_slice() { - ["library", "prompts", prompt_dir, "preset.json"] => { - let existing = prompt_ref(state, prompt_dir).ok(); - let id = existing.as_ref().map(|record| record.id.clone()); - let record = upsert_record(state, "prompts", id, merge_for_parent_file(state, "prompts", existing.as_ref().map(|record| record.id.as_str()), content, &["sectionOrder", "groupOrder", "variableGroups", "variableValues", "sections", "groups", "variables"])?)?; - Ok(json!({ "path": prompt_record_path(state, &record, "preset.json")?, "record": strip_prompt_children(record) })) - } - ["library", "prompts", prompt_dir, child_dir, filename] => { - let prompt = prompt_ref(state, prompt_dir)?; - let mount = prompt_child_mount(child_dir)?; - let existing_id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename).ok(); - let mut object = ensure_json_object(content)?; - object.insert("presetId".to_string(), Value::String(prompt.id.clone())); - let record = upsert_record(state, mount.collection, existing_id, Value::Object(object))?; - Ok(json!({ "path": child_record_path(state, &prompt, mount, &record)?, "record": record })) - } - ["library", "lorebooks", lorebook_dir, "book.json"] => { - let existing = lorebook_ref(state, lorebook_dir).ok(); - let id = existing.as_ref().map(|record| record.id.clone()); - let record = upsert_record(state, "lorebooks", id, merge_for_parent_file(state, "lorebooks", existing.as_ref().map(|record| record.id.as_str()), content, &["entries", "folders"])?)?; - Ok(json!({ "path": lorebook_record_path(state, &record, "book.json")?, "record": strip_lorebook_children(record) })) - } - ["library", "lorebooks", lorebook_dir, "entries", filename] => { - let lorebook = lorebook_ref(state, lorebook_dir)?; - let existing_id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename).ok(); - let mut object = ensure_json_object(content)?; - object.insert("lorebookId".to_string(), Value::String(lorebook.id.clone())); - let record = upsert_record(state, "lorebook-entries", existing_id, Value::Object(object))?; - Ok(json!({ "path": child_record_path(state, &lorebook, &LOREBOOK_ENTRY_MOUNT, &record)?, "record": record })) - } - ["library", dir, filename] => { - let mount = root_mount(dir)?; - if mount.collection == "prompts" || mount.collection == "lorebooks" { - return Err(AppError::invalid_input(format!("Write {} records through their nested parent file", mount.label))); - } - let existing_id = resolve_record_id_from_file_name(state, mount, None, filename).ok(); - let record = upsert_record(state, mount.collection, existing_id, content)?; - Ok(json!({ "path": flat_record_path(state, mount, &record)?, "record": record })) - } - _ => Err(AppError::invalid_input(format!("Path is not writable in Professor Mari workspace: {normalized}"))), - } -} -fn read_content(state: &AppState, normalized: &str) -> AppResult { - let parts = parts(normalized); - match parts.as_slice() { - ["help.md"] => Ok(help_text()), - ["library", "prompts", "index.json"] => prompt_index_content(state), - ["library", "prompts", prompt_dir, "preset.json"] => { - let prompt = read_prompt_record(state, prompt_dir)?; - pretty(strip_prompt_children(prompt)) - } - ["library", "prompts", prompt_dir, child_dir, "index.json"] => prompt_child_index_content(state, prompt_dir, child_dir), - ["library", "prompts", prompt_dir, child_dir, filename] => read_prompt_child_record(state, prompt_dir, child_dir, filename), - ["library", "lorebooks", "index.json"] => lorebook_index_content(state), - ["library", "lorebooks", lorebook_dir, "book.json"] => { - let lorebook = read_lorebook_record(state, lorebook_dir)?; - pretty(strip_lorebook_children(lorebook)) - } - ["library", "lorebooks", lorebook_dir, "entries", "index.json"] => lorebook_entries_index_content(state, lorebook_dir), - ["library", "lorebooks", lorebook_dir, "entries", filename] => read_lorebook_entry_record(state, lorebook_dir, filename), - ["library", dir, "index.json"] => { - let mount = root_mount(dir)?; - flat_index_content(state, mount) - } - ["library", dir, filename] => { - let mount = root_mount(dir)?; - let id = resolve_record_id_from_file_name(state, mount, None, filename)?; - let row = state.storage.get(mount.collection, &id)?.ok_or_else(|| AppError::not_found(format!("No record at {normalized}")))?; - pretty(row) - } - ["schema", filename] => schema_content(filename), - _ => Err(AppError::not_found(format!("No such file in Professor Mari workspace: {normalized}"))), - } -} - -fn ls_flat_collection(state: &AppState, mount: &MariMount) -> AppResult { - let records = record_refs(state, mount, None)?; - let mut entries = vec![json!({ - "name": "index.json", - "type": "file", - "path": format!("/library/{}/index.json", mount.dir), - })]; - entries.extend(records.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "name": name, - "type": "file", - "path": record.path, - "entity": mount.collection, - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - }) - })); - Ok(json!({ "path": format!("/library/{}", mount.dir), "entries": entries })) -} - -fn ls_parent_collection(state: &AppState, mount: &MariMount, as_dirs: bool) -> AppResult { - let records = record_refs(state, mount, None)?; - let mut entries = vec![json!({ - "name": "index.json", - "type": "file", - "path": format!("/library/{}/index.json", mount.dir), - })]; - entries.extend(records.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "name": name, - "type": if as_dirs { "directory" } else { "file" }, - "path": record.path, - "entity": mount.collection, - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - }) - })); - Ok(json!({ "path": format!("/library/{}", mount.dir), "entries": entries })) -} - -fn ls_prompt_dir(state: &AppState, prompt_dir: &str) -> AppResult { - let prompt = prompt_ref(state, prompt_dir)?; - Ok(json!({ - "path": prompt.path, - "label": prompt.label, - "entries": [ - { "name": "preset.json", "type": "file", "path": format!("{}/preset.json", prompt.path) }, - { "name": "sections", "type": "directory", "path": format!("{}/sections", prompt.path) }, - { "name": "groups", "type": "directory", "path": format!("{}/groups", prompt.path) }, - { "name": "variables", "type": "directory", "path": format!("{}/variables", prompt.path) } - ] - })) -} - -fn ls_prompt_child_dir(state: &AppState, prompt_dir: &str, child_dir: &str) -> AppResult { - let prompt = prompt_ref(state, prompt_dir)?; - let mount = prompt_child_mount(child_dir)?; - let records = record_refs(state, mount, Some(("presetId", prompt.id.as_str())))?; - let base = format!("{}/{}", prompt.path, child_dir); - let mut entries = vec![json!({ "name": "index.json", "type": "file", "path": format!("{base}/index.json") })]; - entries.extend(records.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "name": name, - "type": "file", - "path": format!("{base}/{name}"), - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - }) - })); - Ok(json!({ "path": base, "entries": entries })) -} - -fn ls_lorebook_dir(state: &AppState, lorebook_dir: &str) -> AppResult { - let lorebook = lorebook_ref(state, lorebook_dir)?; - Ok(json!({ - "path": lorebook.path, - "label": lorebook.label, - "entries": [ - { "name": "book.json", "type": "file", "path": format!("{}/book.json", lorebook.path) }, - { "name": "entries", "type": "directory", "path": format!("{}/entries", lorebook.path) } - ] - })) -} - -fn ls_lorebook_entries_dir(state: &AppState, lorebook_dir: &str) -> AppResult { - let lorebook = lorebook_ref(state, lorebook_dir)?; - let records = record_refs(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())))?; - let base = format!("{}/entries", lorebook.path); - let mut entries = vec![json!({ "name": "index.json", "type": "file", "path": format!("{base}/index.json") })]; - entries.extend(records.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "name": name, - "type": "file", - "path": format!("{base}/{name}"), - "ref": format!("{}-{:03}", LOREBOOK_ENTRY_MOUNT.prefix, record.ordinal), - "label": record.label, - }) - })); - Ok(json!({ "path": base, "entries": entries })) -} - -fn flat_index_content(state: &AppState, mount: &MariMount) -> AppResult { - let items = record_refs(state, mount, None)?.into_iter().map(index_item).collect::>(); - pretty(json!({ - "collection": mount.collection, - "label": mount.label, - "count": items.len(), - "idPolicy": "Paths and refs are user-friendly aliases. Internal storage ids are hidden in listings.", - "items": items, - })) -} - -fn prompt_index_content(state: &AppState) -> AppResult { - let mount = root_mount("prompts")?; - let items = record_refs(state, mount, None)?.into_iter().map(|record| json!({ - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - "path": record.path, - "presetPath": format!("{}/preset.json", record.path), - "sectionsPath": format!("{}/sections", record.path), - "groupsPath": format!("{}/groups", record.path), - "variablesPath": format!("{}/variables", record.path), - })).collect::>(); - pretty(json!({ - "collection": "prompts", - "label": "Prompt presets", - "count": items.len(), - "organization": "Prompt sections, groups, and variables are nested under each preset to avoid duplicating prompt internals at the library root.", - "items": items, - })) -} - -fn lorebook_index_content(state: &AppState) -> AppResult { - let mount = root_mount("lorebooks")?; - let items = record_refs(state, mount, None)?.into_iter().map(|record| json!({ - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - "path": record.path, - "bookPath": format!("{}/book.json", record.path), - "entriesPath": format!("{}/entries", record.path), - })).collect::>(); - pretty(json!({ - "collection": "lorebooks", - "label": "Lorebooks", - "count": items.len(), - "organization": "Lorebook entries are nested under their owning lorebook to avoid duplicating child records at the library root.", - "items": items, - })) -} - -fn prompt_child_index_content(state: &AppState, prompt_dir: &str, child_dir: &str) -> AppResult { - let prompt = prompt_ref(state, prompt_dir)?; - let mount = prompt_child_mount(child_dir)?; - let base = format!("{}/{}", prompt.path, child_dir); - let items = record_refs(state, mount, Some(("presetId", prompt.id.as_str())))?.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "ref": format!("{}-{:03}", mount.prefix, record.ordinal), - "label": record.label, - "path": format!("{base}/{name}"), - }) - }).collect::>(); - pretty(json!({ - "collection": mount.collection, - "parentPreset": prompt.label, - "count": items.len(), - "items": items, - })) -} - -fn lorebook_entries_index_content(state: &AppState, lorebook_dir: &str) -> AppResult { - let lorebook = lorebook_ref(state, lorebook_dir)?; - let base = format!("{}/entries", lorebook.path); - let items = record_refs(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())))?.into_iter().map(|record| { - let name = record.path.rsplit('/').next().unwrap_or(record.path.as_str()); - json!({ - "ref": format!("{}-{:03}", LOREBOOK_ENTRY_MOUNT.prefix, record.ordinal), - "label": record.label, - "path": format!("{base}/{name}"), - }) - }).collect::>(); - pretty(json!({ - "collection": LOREBOOK_ENTRY_MOUNT.collection, - "parentLorebook": lorebook.label, - "count": items.len(), - "items": items, - })) -} - -fn read_prompt_record(state: &AppState, prompt_dir: &str) -> AppResult { - let prompt = prompt_ref(state, prompt_dir)?; - state.storage.get("prompts", &prompt.id)?.ok_or_else(|| AppError::not_found(format!("No prompt preset at {}", prompt.path))) -} - -fn read_lorebook_record(state: &AppState, lorebook_dir: &str) -> AppResult { - let lorebook = lorebook_ref(state, lorebook_dir)?; - state.storage.get("lorebooks", &lorebook.id)?.ok_or_else(|| AppError::not_found(format!("No lorebook at {}", lorebook.path))) -} - -fn read_prompt_child_record(state: &AppState, prompt_dir: &str, child_dir: &str, filename: &str) -> AppResult { - let prompt = prompt_ref(state, prompt_dir)?; - let mount = prompt_child_mount(child_dir)?; - let id = resolve_record_id_from_file_name(state, mount, Some(("presetId", prompt.id.as_str())), filename)?; - let row = state.storage.get(mount.collection, &id)?.ok_or_else(|| AppError::not_found(format!("No prompt child record for {filename}")))?; - if row.get("presetId").and_then(Value::as_str) != Some(prompt.id.as_str()) { - return Err(AppError::not_found(format!("No {child_dir} record named {filename} under {}", prompt.label))); - } - pretty(row) -} - -fn read_lorebook_entry_record(state: &AppState, lorebook_dir: &str, filename: &str) -> AppResult { - let lorebook = lorebook_ref(state, lorebook_dir)?; - let id = resolve_record_id_from_file_name(state, &LOREBOOK_ENTRY_MOUNT, Some(("lorebookId", lorebook.id.as_str())), filename)?; - let row = state.storage.get("lorebook-entries", &id)?.ok_or_else(|| AppError::not_found(format!("No lorebook entry record for {filename}")))?; - if row.get("lorebookId").and_then(Value::as_str) != Some(lorebook.id.as_str()) { - return Err(AppError::not_found(format!("No entry named {filename} under {}", lorebook.label))); - } - pretty(row) -} - -fn record_refs(state: &AppState, mount: &MariMount, filter: Option<(&str, &str)>) -> AppResult> { - let mut rows = filtered_rows(state, mount.collection, filter)?; - rows.sort_by(|a, b| { - let a_display = record_label(a, mount).unwrap_or_else(|| row_id(a).unwrap_or_default()).to_ascii_lowercase(); - let b_display = record_label(b, mount).unwrap_or_else(|| row_id(b).unwrap_or_default()).to_ascii_lowercase(); - a_display.cmp(&b_display).then_with(|| row_id(a).cmp(&row_id(b))) - }); - Ok(rows.iter().enumerate().map(|(index, row)| { - let ordinal = index + 1; - let id = row_id(row).unwrap_or_else(|| format!("missing-id-{ordinal}")); - let label = record_label(row, mount).unwrap_or_else(|| format!("{} {}", singular_label(mount.label), ordinal)); - let path = format!("/library/{}/{}", mount.dir, alias_name(mount, ordinal, &label, false)); - MariRecordRef { ordinal, id, label, path } - }).collect()) -} - -fn filtered_rows(state: &AppState, collection: &str, filter: Option<(&str, &str)>) -> AppResult> { - let rows = state.storage.list(collection)?; - Ok(match filter { - Some((key, expected)) => rows.into_iter().filter(|row| row.get(key).and_then(Value::as_str) == Some(expected)).collect(), - None => rows, - }) -} - -fn prompt_ref(state: &AppState, prompt_dir: &str) -> AppResult { - resolve_parent_ref(state, root_mount("prompts")?, prompt_dir) -} - -fn lorebook_ref(state: &AppState, lorebook_dir: &str) -> AppResult { - resolve_parent_ref(state, root_mount("lorebooks")?, lorebook_dir) -} - -fn resolve_parent_ref(state: &AppState, mount: &MariMount, dir_name: &str) -> AppResult { - let records = record_refs(state, mount, None)?; - if let Some(ordinal) = alias_ordinal(dir_name, mount.prefix) { - return records.into_iter().find(|record| record.ordinal == ordinal).ok_or_else(|| AppError::not_found(format!("No {} record for alias {}-{:03}", mount.label, mount.prefix, ordinal))); - } - records.into_iter().find(|record| record.id == dir_name).ok_or_else(|| AppError::not_found(format!("No {} record named {dir_name}", mount.label))) -} - -fn resolve_record_id_from_file_name(state: &AppState, mount: &MariMount, filter: Option<(&str, &str)>, filename: &str) -> AppResult { - let without_ext = filename.strip_suffix(".json").unwrap_or(filename); - if let Some(ordinal) = alias_ordinal(without_ext, mount.prefix) { - return record_refs(state, mount, filter)?.into_iter().find(|record| record.ordinal == ordinal).map(|record| record.id).ok_or_else(|| AppError::not_found(format!("No {} record for alias {}-{:03}", mount.label, mount.prefix, ordinal))); - } - let id = without_ext.rsplit_once("__").map(|(_, id)| id).unwrap_or(without_ext).trim(); - if id.is_empty() { - return Err(AppError::invalid_input("read path is missing a record alias")); - } - Ok(id.to_string()) -} - -fn upsert_record(state: &AppState, collection: &str, id: Option, content: Value) -> AppResult { - let mut object = ensure_json_object(content)?; - if let Some(id) = id.filter(|id| !id.trim().is_empty()) { - object.insert("id".to_string(), Value::String(id.clone())); - state.storage.upsert_with_id(collection, &id, Value::Object(object)) - } else { - state.storage.create(collection, Value::Object(object)) - } -} - -fn merge_for_parent_file(state: &AppState, collection: &str, id: Option<&str>, content: Value, preserve_keys: &[&str]) -> AppResult { - let mut next = ensure_json_object(content)?; - if let Some(id) = id { - if let Some(existing) = state.storage.get(collection, id)? { - if let Some(existing_object) = existing.as_object() { - for key in preserve_keys { - if let Some(value) = existing_object.get(*key) { - next.insert((*key).to_string(), value.clone()); - } - } - } - } - } - Ok(Value::Object(next)) -} - -fn ensure_json_object(value: Value) -> AppResult> { - match value { - Value::Object(object) => Ok(object), - _ => Err(AppError::invalid_input("write content must be a JSON object")), - } -} - -fn flat_record_path(state: &AppState, mount: &MariMount, row: &Value) -> AppResult { - let id = row_id(row).ok_or_else(|| AppError::invalid_input("written record is missing an id"))?; - record_refs(state, mount, None)?.into_iter().find(|record| record.id == id).map(|record| record.path).ok_or_else(|| AppError::not_found("written record path could not be resolved")) -} - -fn prompt_record_path(state: &AppState, row: &Value, file_name: &str) -> AppResult { - let prompt_mount = root_mount("prompts")?; - Ok(format!("{}/{}", flat_record_path(state, prompt_mount, row)?, file_name)) -} - -fn lorebook_record_path(state: &AppState, row: &Value, file_name: &str) -> AppResult { - let lorebook_mount = root_mount("lorebooks")?; - Ok(format!("{}/{}", flat_record_path(state, lorebook_mount, row)?, file_name)) -} - -fn child_record_path(state: &AppState, parent: &MariRecordRef, mount: &MariMount, row: &Value) -> AppResult { - let id = row_id(row).ok_or_else(|| AppError::invalid_input("written child record is missing an id"))?; - let parent_key = if mount.collection == "lorebook-entries" { "lorebookId" } else { "presetId" }; - let base = format!("{}/{}", parent.path, mount.dir); - record_refs(state, mount, Some((parent_key, parent.id.as_str())))? - .into_iter() - .find(|record| record.id == id) - .and_then(|record| record.path.rsplit('/').next().map(|name| format!("{base}/{name}"))) - .ok_or_else(|| AppError::not_found("written child record path could not be resolved")) -} - -fn index_item(record: MariRecordRef) -> Value { - let prefix = record.path.rsplit('/').next().and_then(|name| name.split('-').next()).unwrap_or("record"); - json!({ - "ref": format!("{}-{:03}", prefix, record.ordinal), - "label": record.label, - "path": record.path, - }) -} - -fn strip_prompt_children(value: Value) -> Value { - strip_keys(value, &["sectionOrder", "groupOrder", "variableGroups", "variableValues", "sections", "groups", "variables"]) -} - -fn strip_lorebook_children(value: Value) -> Value { - strip_keys(value, &["entries", "folders"]) -} - -fn strip_keys(value: Value, keys: &[&str]) -> Value { - match value { - Value::Object(mut object) => { - for key in keys { - object.remove(*key); - } - Value::Object(object) - } - other => other, - } -} - -fn schema_entries() -> Vec { - vec![ - json!({ "name": "characters.json", "path": "/schema/characters.json", "type": "file" }), - json!({ "name": "character-groups.json", "path": "/schema/character-groups.json", "type": "file" }), - json!({ "name": "personas.json", "path": "/schema/personas.json", "type": "file" }), - json!({ "name": "persona-groups.json", "path": "/schema/persona-groups.json", "type": "file" }), - json!({ "name": "lorebooks.json", "path": "/schema/lorebooks.json", "type": "file" }), - json!({ "name": "lorebook-entries.json", "path": "/schema/lorebook-entries.json", "type": "file" }), - json!({ "name": "prompts.json", "path": "/schema/prompts.json", "type": "file" }), - json!({ "name": "prompt-sections.json", "path": "/schema/prompt-sections.json", "type": "file" }), - json!({ "name": "prompt-groups.json", "path": "/schema/prompt-groups.json", "type": "file" }), - json!({ "name": "prompt-variables.json", "path": "/schema/prompt-variables.json", "type": "file" }), - ] -} - -fn schema_content(filename: &str) -> AppResult { - let stem = filename.strip_suffix(".json").ok_or_else(|| AppError::not_found(format!("No schema file: /schema/{filename}")))?; - let notes = match stem { - "prompts" => "Prompt presets are directories. Read preset.json for preset metadata; sections, groups, and variables are nested child directories and are not duplicated in preset.json.", - "lorebooks" => "Lorebooks are directories. Read book.json for book metadata; entries are nested under entries/ and are not duplicated in book.json.", - "prompt-sections" | "prompt-groups" | "prompt-variables" => "Prompt internals are only listed under their owning preset at /library/prompts//.", - "lorebook-entries" => "Lorebook entries are only listed under their owning lorebook at /library/lorebooks//entries/.", - _ => "Records are exposed through user-friendly alias paths. This workspace is read-only.", - }; - pretty(json!({ "schema": stem, "notes": notes })) -} - -fn normalize_path(path: &str) -> AppResult { - let trimmed = path.trim(); - if trimmed.is_empty() { - return Ok("/".to_string()); - } - if trimmed.contains('\\') || trimmed.contains(':') || trimmed.split('/').any(|part| part == "..") { - return Err(AppError::invalid_input("Professor Mari paths must stay inside the virtual workspace")); - } - let normalized = format!("/{}", trimmed.trim_matches('/')); - Ok(if normalized == "/" { "/".to_string() } else { normalized }) -} - -fn parts(path: &str) -> Vec<&str> { - path.trim_matches('/').split('/').filter(|part| !part.is_empty()).collect() -} - -fn root_mount(dir: &str) -> AppResult<&'static MariMount> { - ROOT_MOUNTS.iter().find(|mount| mount.dir == dir).ok_or_else(|| AppError::not_found(format!("No such Professor Mari library directory: {dir}"))) -} - -fn prompt_child_mount(dir: &str) -> AppResult<&'static MariMount> { - PROMPT_CHILD_MOUNTS.iter().find(|mount| mount.dir == dir).ok_or_else(|| AppError::not_found(format!("No such prompt child directory: {dir}"))) -} - -fn row_id(row: &Value) -> Option { - row.get("id").and_then(Value::as_str).filter(|id| !id.trim().is_empty()).map(str::to_string) -} - -fn record_label(row: &Value, mount: &MariMount) -> Option { - let candidates: &[&[&str]] = match mount.collection { - "characters" => &[&["data", "name"]], - "personas" | "persona-groups" | "character-groups" | "lorebooks" | "lorebook-entries" | "prompts" | "prompt-sections" | "prompt-groups" => &[&["name"]], - "prompt-variables" => &[&["label"], &["name"], &["variableName"]], - _ => &[&["name"]], - }; - candidates.iter().find_map(|path| nested_string_value(row, path)).map(|value| value.trim().to_string()).filter(|value| !value.is_empty()).filter(|value| !looks_like_internal_id(value)).map(|value| value.to_string()) -} - -fn nested_string_value(value: &Value, path: &[&str]) -> Option { - if path.is_empty() { - return value.as_str().map(str::to_string); - } - let mut current = value; - for (index, key) in path.iter().enumerate() { - current = current.get(*key)?; - if let Value::String(text) = current { - if index + 1 == path.len() { - return Some(text.clone()); - } - let parsed = serde_json::from_str::(text).ok()?; - return nested_string_value(&parsed, &path[index + 1..]); - } - } - current.as_str().map(str::to_string) -} - -fn alias_name(mount: &MariMount, ordinal: usize, label: &str, extension: bool) -> String { - let base = format!("{}-{:03}-{}", mount.prefix, ordinal, slug(label)); - if extension { format!("{base}.json") } else { base } -} - -fn alias_ordinal(value: &str, prefix: &str) -> Option { - let rest = value.strip_prefix(prefix)?.strip_prefix('-')?; - let digits = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect::(); - if digits.is_empty() { None } else { digits.parse::().ok().filter(|ordinal| *ordinal > 0) } -} - -fn slug(value: &str) -> String { - let slug = value.chars().map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' }).collect::().split('-').filter(|part| !part.is_empty()).collect::>().join("-"); - if slug.is_empty() { "record".to_string() } else { slug.chars().take(48).collect() } -} - -fn looks_like_internal_id(value: &str) -> bool { - let trimmed = value.trim(); - if trimmed.len() >= 20 && trimmed.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { - let has_digit = trimmed.chars().any(|ch| ch.is_ascii_digit()); - let has_alpha = trimmed.chars().any(|ch| ch.is_ascii_alphabetic()); - return has_digit && has_alpha && !trimmed.contains(' '); - } - false -} - -fn singular_label(label: &str) -> String { - match label { - "Characters" => "Character".to_string(), - "Personas" => "Persona".to_string(), - "Lorebooks" => "Lorebook".to_string(), - "Lorebook entries" => "Lorebook entry".to_string(), - "Prompt presets" => "Prompt preset".to_string(), - "Prompt sections" => "Prompt section".to_string(), - "Prompt groups" => "Prompt group".to_string(), - "Prompt variables" => "Prompt variable".to_string(), - "Character groups" => "Character group".to_string(), - "Persona groups" => "Persona group".to_string(), - other => other.trim_end_matches('s').to_string(), - } -} - -fn pretty(value: Value) -> AppResult { - serde_json::to_string_pretty(&value).map_err(|error| AppError::new("mari_fs_serialize_failed", error.to_string())) -} - -fn help_text() -> String { - "# Professor Mari virtual workspace\n\nThis is a read-only virtual filesystem backed by Marinara's creative library.\n\nAvailable commands:\n- ls({ path }) lists directories.\n- read({ path, offset?, limit? }) reads JSON or markdown files.\n\nTop-level library folders are characters, character-groups, personas, persona-groups, lorebooks, and prompts. Prompt sections/groups/variables live under their owning prompt preset. Lorebook entries live under their owning lorebook. Parent files such as preset.json and book.json intentionally omit child records to avoid duplication.\n".to_string() -} diff --git a/src-tauri/src/commands/storage/profile/legacy.rs b/src-tauri/src/commands/storage/profile/legacy.rs index 97b241bd3..f5e863f73 100644 --- a/src-tauri/src/commands/storage/profile/legacy.rs +++ b/src-tauri/src/commands/storage/profile/legacy.rs @@ -171,10 +171,7 @@ fn add_legacy_lorebook_links(rows: &mut [Value], tables: &Map) { // editor crashes on `formTags.map is not a function`, and the junction // links computed above would be discarded by `or_insert_with` whenever // the row carried a text-encoded `"[]"` placeholder. - normalize_legacy_text_array_fields( - row, - &["tags", "characterIds", "personaIds"], - ); + normalize_legacy_text_array_fields(row, &["tags", "characterIds", "personaIds"]); // Pre-refactor also stored bool columns as TEXT (`"false"` / `"true"`). // Without coercion, the frontend reads `lorebook.isGlobal === "false"` // as truthy and renders every scoped lorebook as global in the editor. diff --git a/src-tauri/src/http_dispatch.rs b/src-tauri/src/http_dispatch.rs index a2253f489..1f28970b6 100644 --- a/src-tauri/src/http_dispatch.rs +++ b/src-tauri/src/http_dispatch.rs @@ -1,6 +1,8 @@ use crate::builtins::is_protected_record; use crate::state::AppState; -use crate::storage_commands::{avatars, chats, generation, images, imports, llm, lorebook_images, shared}; +use crate::storage_commands::{ + avatars, chats, generation, images, imports, llm, lorebook_images, shared, +}; use marinara_core::{AppError, AppResult}; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -61,7 +63,9 @@ pub async fn dispatch(state: &AppState, request: InvokeRequest) -> AppResult import_call(state, &args, &["marinara"], "envelope"), "import_marinara_file" => import_call(state, &args, &["marinara-file"], "body"), "import_st_character" => import_call(state, &args, &["st-character"], "body"), - "import_st_character_batch" => import_call(state, &args, &["st-character", "batch"], "body"), + "import_st_character_batch" => { + import_call(state, &args, &["st-character", "batch"], "body") + } "import_st_character_inspect" => { import_call(state, &args, &["st-character", "inspect"], "body") } From 7deaedd8b1fb43f7ae6cc964cae0b4a56c4ebf4d Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 16:22:39 -0500 Subject: [PATCH 3/8] Update Prof Mari workspace for typed storage --- .../commands/storage/commands/integrations.rs | 7 +- src-tauri/src/commands/storage/exports.rs | 8 +- .../src/commands/storage/imports/marinara.rs | 11 ++- .../src/commands/storage/imports/service.rs | 8 +- src-tauri/src/commands/storage/mari.rs | 84 ++++------------- src-tauri/src/commands/storage/shared.rs | 94 +++++++++++++++---- src-tauri/src/commands/storage/sprites.rs | 11 ++- 7 files changed, 118 insertions(+), 105 deletions(-) diff --git a/src-tauri/src/commands/storage/commands/integrations.rs b/src-tauri/src/commands/storage/commands/integrations.rs index c0fcd8629..f1eb61d1c 100644 --- a/src-tauri/src/commands/storage/commands/integrations.rs +++ b/src-tauri/src/commands/storage/commands/integrations.rs @@ -104,7 +104,12 @@ pub async fn spotify_authorize( .get("authUrl") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| AppError::new("spotify_authorize_failed", "Authorize request did not return an auth URL"))?; + .ok_or_else(|| { + AppError::new( + "spotify_authorize_failed", + "Authorize request did not return an auth URL", + ) + })?; tauri_plugin_opener::open_url(auth_url, None::<&str>) .map_err(|error| AppError::new("spotify_authorize_open_failed", error.to_string()))?; Ok(response) diff --git a/src-tauri/src/commands/storage/exports.rs b/src-tauri/src/commands/storage/exports.rs index dd757eb83..cce3dd0bc 100644 --- a/src-tauri/src/commands/storage/exports.rs +++ b/src-tauri/src/commands/storage/exports.rs @@ -503,11 +503,9 @@ fn patch_character_embedded_lorebook_pointer( "entriesImported": entries_imported }), ); - state.storage.patch( - "characters", - character_id, - json!({ "data": data }), - )?; + state + .storage + .patch("characters", character_id, json!({ "data": data }))?; Ok(()) } diff --git a/src-tauri/src/commands/storage/imports/marinara.rs b/src-tauri/src/commands/storage/imports/marinara.rs index 3ce92505d..802178f06 100644 --- a/src-tauri/src/commands/storage/imports/marinara.rs +++ b/src-tauri/src/commands/storage/imports/marinara.rs @@ -92,7 +92,11 @@ pub(super) fn import_marinara_file(state: &AppState, body: Value) -> AppResult Option { - record.get("data").and_then(|data| data.get("name")).and_then(Value::as_str).map(ToOwned::to_owned) + record + .get("data") + .and_then(|data| data.get("name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) } fn data_image_string(value: Option<&Value>) -> Option { @@ -345,7 +349,10 @@ fn import_marinara_character(state: &AppState, data: Value) -> AppResult remove_fields(&mut source, &["id", "sprites", "gallery", "metadata"]); if let Some(object) = source.as_object_mut() { if let Some(Value::String(raw)) = object.get("data") { - let parsed = serde_json::from_str::(raw).ok().filter(Value::is_object).unwrap_or_else(|| json!({})); + let parsed = serde_json::from_str::(raw) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); object.insert("data".to_string(), parsed); } } diff --git a/src-tauri/src/commands/storage/imports/service.rs b/src-tauri/src/commands/storage/imports/service.rs index 855cbc164..9b367253f 100644 --- a/src-tauri/src/commands/storage/imports/service.rs +++ b/src-tauri/src/commands/storage/imports/service.rs @@ -86,11 +86,9 @@ fn patch_imported_character_lorebook_pointer( "entriesImported": entries_imported }), ); - state.storage.patch( - "characters", - character_id, - json!({ "data": data }), - )?; + state + .storage + .patch("characters", character_id, json!({ "data": data }))?; Ok(()) } diff --git a/src-tauri/src/commands/storage/mari.rs b/src-tauri/src/commands/storage/mari.rs index 06e3f1590..c8e34fd95 100644 --- a/src-tauri/src/commands/storage/mari.rs +++ b/src-tauri/src/commands/storage/mari.rs @@ -572,18 +572,6 @@ fn build_mari_workspace_seed(state: &AppState) -> AppResult { "Untitled Character", &characters, &[ - "description", - "personality", - "scenario", - "firstMessage", - "first_message", - "first_mes", - "greeting", - "mes_example", - "exampleDialogue", - "systemPrompt", - "creatorNotes", - "creator_notes", "data.description", "data.personality", "data.scenario", @@ -592,6 +580,8 @@ fn build_mari_workspace_seed(state: &AppState) -> AppResult { "data.creator_notes", "data.system_prompt", "data.post_history_instructions", + "data.extensions.backstory", + "data.extensions.appearance", ], )?; @@ -894,12 +884,12 @@ fn add_record_folder( ) -> AppResult<()> { for field in text_fields { if let Some(text) = - string_field_path_owned(record, field).filter(|value| !value.trim().is_empty()) + string_field_path(record, field).filter(|value| !value.trim().is_empty()) { add_bound_file( seed, format!("{folder}/{}.md", field_file_name(field)), - text, + text.to_string(), entity, id, field, @@ -1016,7 +1006,7 @@ fn record_label(record: &Value, fallback: &str) -> String { fn record_label_for_entity(entity: &str, record: &Value, fallback: &str) -> String { let candidates: &[&str] = match entity { - "characters" => &["data.name", "name", "title"], + "characters" => &["data.name"], "personas" => &["name", "data.name", "title", "comment"], "lorebooks" => &["name", "title"], "lorebook-entries" => &["comment", "name"], @@ -1026,12 +1016,14 @@ fn record_label_for_entity(entity: &str, record: &Value, fallback: &str) -> Stri "prompt-variables" => &["name", "key", "label", "title"], _ => &["data.name", "name", "title", "label", "comment", "key"], }; - first_non_empty_owned( - candidates + first_non_empty( + &candidates .iter() - .filter_map(|field| string_field_path_owned(record, field)), + .map(|field| string_field_path(record, field)) + .collect::>(), ) - .unwrap_or_else(|| fallback.to_string()) + .unwrap_or(fallback) + .to_string() } fn lorebook_entry_label(record: &Value) -> String { @@ -1053,33 +1045,16 @@ fn first_non_empty<'a>(values: &[Option<&'a str>]) -> Option<&'a str> { .find(|value| !value.is_empty()) } -fn first_non_empty_owned(values: impl IntoIterator) -> Option { - values - .into_iter() - .map(|value| value.trim().to_string()) - .find(|value| !value.is_empty()) -} - fn str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value.get(field).and_then(Value::as_str) } -fn string_field_path_owned(value: &Value, field_path: &str) -> Option { - fn walk(current: &Value, parts: &[&str]) -> Option { - if parts.is_empty() { - return current.as_str().map(str::to_string); - } - if let Some(object) = current.as_object() { - return walk(object.get(parts[0])?, &parts[1..]); - } - if let Some(raw) = current.as_str() { - let parsed = serde_json::from_str::(raw).ok()?; - return walk(&parsed, parts); - } - None +fn string_field_path<'a>(value: &'a Value, field_path: &str) -> Option<&'a str> { + let mut current = value; + for field in field_path.split('.') { + current = current.get(field)?; } - let parts = field_path.split('.').collect::>(); - walk(value, &parts) + current.as_str() } fn display_label(label: &str) -> String { @@ -1107,8 +1082,6 @@ fn string_array_items(value: Option<&Value>) -> Vec { .filter_map(Value::as_str) .map(str::to_string) .collect(), - Some(Value::String(raw)) => serde_json::from_str::>(raw) - .unwrap_or_else(|_| raw.lines().map(str::to_string).collect()), _ => Vec::new(), } } @@ -1120,7 +1093,6 @@ fn keys_text(record: &Value) -> Option { fn metadata_without_fields(record: &Value, text_fields: &[&str]) -> Value { let mut metadata = record.clone(); - expand_json_string_objects(&mut metadata); remove_field_path(&mut metadata, "id"); remove_field_path(&mut metadata, "createdAt"); remove_field_path(&mut metadata, "updatedAt"); @@ -1131,30 +1103,6 @@ fn metadata_without_fields(record: &Value, text_fields: &[&str]) -> Value { metadata } -fn expand_json_string_objects(value: &mut Value) { - match value { - Value::Object(object) => { - for child in object.values_mut() { - expand_json_string_objects(child); - } - } - Value::Array(items) => { - for item in items { - expand_json_string_objects(item); - } - } - Value::String(raw) => { - if let Ok(parsed @ (Value::Object(_) | Value::Array(_))) = - serde_json::from_str::(raw) - { - *value = parsed; - expand_json_string_objects(value); - } - } - _ => {} - } -} - fn remove_field_path(value: &mut Value, field_path: &str) { let mut current = value; let mut parts = field_path.split('.').peekable(); diff --git a/src-tauri/src/commands/storage/shared.rs b/src-tauri/src/commands/storage/shared.rs index 61bd98a3f..4490c3e92 100644 --- a/src-tauri/src/commands/storage/shared.rs +++ b/src-tauri/src/commands/storage/shared.rs @@ -132,7 +132,9 @@ pub(crate) fn swipe_index_value(message: &Value) -> i64 { pub(crate) fn normalize_character_data_for_storage(data: &Value) -> AppResult { match data { Value::Object(_) => Ok(data.clone()), - _ => Err(AppError::invalid_input("Character data must be a JSON object")), + _ => Err(AppError::invalid_input( + "Character data must be a JSON object", + )), } } @@ -142,15 +144,30 @@ pub(crate) fn normalize_update_patch(collection: &str, patch: Value) -> AppResul Ok(Value::Object(object)) } -pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map) -> AppResult<()> { +pub(crate) fn normalize_typed_json_fields( + collection: &str, + object: &mut Map, +) -> AppResult<()> { match collection { "characters" => { if let Some(data) = object.get("data") { - object.insert("data".to_string(), normalize_character_data_for_storage(data)?); + object.insert( + "data".to_string(), + normalize_character_data_for_storage(data)?, + ); } } "chats" => { - normalize_json_array_fields(object, &["characterIds", "activeLorebookIds", "activeAgentIds", "activeToolIds", "memories"])?; + normalize_json_array_fields( + object, + &[ + "characterIds", + "activeLorebookIds", + "activeAgentIds", + "activeToolIds", + "memories", + ], + )?; normalize_nullable_json_object_fields(object, &["metadata", "gameState"])?; } "messages" => { @@ -170,14 +187,20 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { - normalize_nullable_json_object_fields(object, &["defaultParameters", "capabilities", "providerMetadata"])?; + normalize_nullable_json_object_fields( + object, + &["defaultParameters", "capabilities", "providerMetadata"], + )?; } "custom-tools" => { normalize_json_object_fields(object, &["parametersSchema"])?; } "game-state-snapshots" => { normalize_json_array_fields(object, &["presentCharacters", "recentEvents"])?; - normalize_nullable_json_object_fields(object, &["playerStats", "personaStats", "metadata"])?; + normalize_nullable_json_object_fields( + object, + &["playerStats", "personaStats", "metadata"], + )?; } "game-checkpoints" => { normalize_nullable_json_object_fields(object, &["snapshot", "gameState", "metadata"])?; @@ -187,7 +210,10 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { normalize_json_array_fields(object, &["sectionOrder", "groupOrder", "variableOrder"])?; - normalize_json_object_fields(object, &["variableValues", "parameters", "defaultChoices"])?; + normalize_json_object_fields( + object, + &["variableValues", "parameters", "defaultChoices"], + )?; normalize_json_array_fields(object, &["variableGroups"])?; } "prompt-sections" => { @@ -197,7 +223,10 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { - normalize_json_array_fields(object, &["tags", "altDescriptions", "savedStatusOptions"])?; + normalize_json_array_fields( + object, + &["tags", "altDescriptions", "savedStatusOptions"], + )?; normalize_nullable_json_object_fields(object, &["avatarCrop", "personaStats"])?; } "agents" => { @@ -213,7 +242,9 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map, fields: &[&str]) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; let normalized = normalize_json_field(value, Value::is_array, "array", field)?; object.insert((*field).to_string(), normalized); } @@ -222,16 +253,23 @@ fn normalize_json_array_fields(object: &mut Map, fields: &[&str]) fn normalize_json_object_fields(object: &mut Map, fields: &[&str]) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; let normalized = normalize_json_field(value, Value::is_object, "object", field)?; object.insert((*field).to_string(), normalized); } Ok(()) } -fn normalize_nullable_json_object_fields(object: &mut Map, fields: &[&str]) -> AppResult<()> { +fn normalize_nullable_json_object_fields( + object: &mut Map, + fields: &[&str], +) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; if value.is_null() { continue; } @@ -259,13 +297,17 @@ fn normalize_json_field( }); } let parsed: Value = serde_json::from_str(raw).map_err(|_| { - AppError::invalid_input(format!("{field} must be a JSON {expected}, not a JSON string")) + AppError::invalid_input(format!( + "{field} must be a JSON {expected}, not a JSON string" + )) })?; if predicate(&parsed) { return Ok(parsed); } } - Err(AppError::invalid_input(format!("{field} must be a JSON {expected}"))) + Err(AppError::invalid_input(format!( + "{field} must be a JSON {expected}" + ))) } pub(crate) fn with_entity_defaults(collection: &str, body: Value) -> AppResult { @@ -400,7 +442,9 @@ pub(crate) fn with_entity_defaults(collection: &str, body: Value) -> AppResult { normalize_typed_json_fields(collection, &mut object)?; @@ -465,10 +509,14 @@ mod tests { assert_eq!(patch["data"]["tags"], json!(["guide"])); } - #[test] fn character_update_patch_rejects_invalid_data_shape() { - for invalid in [json!(true), json!("{\"name\":\"Professor Mari\"}"), json!([]), Value::Null] { + for invalid in [ + json!(true), + json!("{\"name\":\"Professor Mari\"}"), + json!([]), + Value::Null, + ] { let error = normalize_update_patch("characters", json!({ "data": invalid })) .expect_err("invalid character data should fail"); assert_eq!(error.code, "invalid_input"); @@ -552,7 +600,11 @@ mod tests { for alias in ["1", "yes", "on", "TRUE", " Yes "] { let mut record = json!({ "flag": alias }); normalize_legacy_text_bool_fields(&mut record, &["flag"]); - assert_eq!(record["flag"], json!(true), "alias {alias:?} should be true"); + assert_eq!( + record["flag"], + json!(true), + "alias {alias:?} should be true" + ); } } @@ -561,7 +613,11 @@ mod tests { for alias in ["0", "no", "off", "FALSE", " No "] { let mut record = json!({ "flag": alias }); normalize_legacy_text_bool_fields(&mut record, &["flag"]); - assert_eq!(record["flag"], json!(false), "alias {alias:?} should be false"); + assert_eq!( + record["flag"], + json!(false), + "alias {alias:?} should be false" + ); } } diff --git a/src-tauri/src/commands/storage/sprites.rs b/src-tauri/src/commands/storage/sprites.rs index 3d8c7f1ce..80b86d943 100644 --- a/src-tauri/src/commands/storage/sprites.rs +++ b/src-tauri/src/commands/storage/sprites.rs @@ -303,7 +303,11 @@ pub(crate) fn upload_sprite(state: &AppState, character_id: &str, body: Value) - sprite_info_from_path(&path) } -pub(crate) fn upload_sprites(state: &AppState, character_id: &str, body: Value) -> AppResult { +pub(crate) fn upload_sprites( + state: &AppState, + character_id: &str, + body: Value, +) -> AppResult { validate_safe_segment(character_id, "character ID")?; let uploads = body .get("sprites") @@ -2028,10 +2032,7 @@ mod sprite_upload_tests { assert_eq!(result.get("imported").and_then(Value::as_u64), Some(1)); assert_eq!( - result - .get("failed") - .and_then(Value::as_array) - .map(Vec::len), + result.get("failed").and_then(Value::as_array).map(Vec::len), Some(2) ); let sprites = result From 73e3967b05661d72046af08573cd46756e1b48c2 Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 16:26:12 -0500 Subject: [PATCH 4/8] Add Professor Mari trace events --- src-tauri/src/commands/storage/mari.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/storage/mari.rs b/src-tauri/src/commands/storage/mari.rs index c8e34fd95..049995faf 100644 --- a/src-tauri/src/commands/storage/mari.rs +++ b/src-tauri/src/commands/storage/mari.rs @@ -129,12 +129,13 @@ pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppR }), )?; let connection = llm_connection_from_value(&connection_value)?; - let (content, action) = run_mari_agent(state, connection, &input).await?; + let (content, action, trace) = run_mari_agent(state, connection, &input).await?; Ok(json!({ "content": content, "createdAt": chrono::Utc::now().to_rfc3339(), "action": action, + "trace": trace, })) } @@ -142,11 +143,11 @@ async fn run_mari_agent( state: &AppState, connection: marinara_llm::LlmConnection, input: &MariPromptRequest, -) -> AppResult<(String, Value)> { +) -> AppResult<(String, Value, Vec)> { let workspace_seed = build_mari_workspace_seed(state)?; let session = MariShellSession::new(input, workspace_seed).await?; let tools = build_pi_like_tools(session.clone()); - let llm: Arc = Arc::new(MarinaraLlmProvider::new(connection)); + let llm: Arc = Arc::new(MarinaraLlmProvider::new(connection, session.clone())); let agent = ReActAgent::with_max_turns(ProfessorMariAgent { tools }, 8); let agent_handle = AgentBuilder::<_, DirectAgent>::new(agent) .llm(llm) @@ -166,17 +167,22 @@ async fn run_mari_agent( } else { content.to_string() }; - Ok((content, staged_mari_action_contract(&session).await?)) + Ok(( + content, + staged_mari_action_contract(&session).await?, + session.trace_events(), + )) } #[derive(Clone)] struct MarinaraLlmProvider { connection: marinara_llm::LlmConnection, + session: Arc, } impl MarinaraLlmProvider { - fn new(connection: marinara_llm::LlmConnection) -> Self { - Self { connection } + fn new(connection: marinara_llm::LlmConnection, session: Arc) -> Self { + Self { connection, session } } async fn complete_chat( @@ -198,6 +204,13 @@ impl MarinaraLlmProvider { }) .await .map_err(|error| LLMError::ProviderError(format_app_error_for_debug(&error)))?; + self.session.record_trace(json!({ + "type": "model_turn", + "label": "Model turn", + "summary": model_turn_summary(&response.content, &response.tool_calls), + "content": truncate_tool_text(response.content.trim()), + "toolCalls": response.tool_calls.iter().map(summarize_tool_call_value).collect::>(), + })); Ok(MarinaraChatResponse { content: response.content, tool_calls: map_marinara_tool_calls(response.tool_calls), From 1b1e793231fa0779bc45ebd5efe7ec6d003b9ef8 Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 16:39:11 -0500 Subject: [PATCH 5/8] Show live Prof Mari agent activity --- .../src/commands/storage/commands/mari.rs | 3 +- src-tauri/src/commands/storage/mari.rs | 154 +++++++++++++++++- src/engine/mari/mari-entry.ts | 39 ++++- .../mari/components/ProfessorMariSurface.tsx | 126 +++++++++++++- src/shared/api/mari-api.ts | 14 +- 5 files changed, 319 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/storage/commands/mari.rs b/src-tauri/src/commands/storage/commands/mari.rs index a1fecb9ea..faa3f37e9 100644 --- a/src-tauri/src/commands/storage/commands/mari.rs +++ b/src-tauri/src/commands/storage/commands/mari.rs @@ -8,6 +8,7 @@ use tauri::State; pub async fn professor_mari_prompt( state: State<'_, AppState>, request: Value, + on_event: tauri::ipc::Channel, ) -> Result { - mari::professor_mari_prompt(&state, request).await + mari::professor_mari_prompt(&state, request, on_event).await } diff --git a/src-tauri/src/commands/storage/mari.rs b/src-tauri/src/commands/storage/mari.rs index 049995faf..c4e639825 100644 --- a/src-tauri/src/commands/storage/mari.rs +++ b/src-tauri/src/commands/storage/mari.rs @@ -119,7 +119,11 @@ struct MariWorkspaceFile { content: String, } -pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppResult { +pub(crate) async fn professor_mari_prompt( + state: &AppState, + body: Value, + trace_channel: tauri::ipc::Channel, +) -> AppResult { let input: MariPromptRequest = serde_json::from_value(body).map_err(|error| AppError::invalid_input(error.to_string()))?; let connection_value = resolve_llm_connection_for_request( @@ -129,7 +133,7 @@ pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppR }), )?; let connection = llm_connection_from_value(&connection_value)?; - let (content, action, trace) = run_mari_agent(state, connection, &input).await?; + let (content, action, trace) = run_mari_agent(state, connection, &input, trace_channel).await?; Ok(json!({ "content": content, @@ -143,9 +147,10 @@ async fn run_mari_agent( state: &AppState, connection: marinara_llm::LlmConnection, input: &MariPromptRequest, + trace_channel: tauri::ipc::Channel, ) -> AppResult<(String, Value, Vec)> { let workspace_seed = build_mari_workspace_seed(state)?; - let session = MariShellSession::new(input, workspace_seed).await?; + let session = MariShellSession::new(input, workspace_seed, trace_channel).await?; let tools = build_pi_like_tools(session.clone()); let llm: Arc = Arc::new(MarinaraLlmProvider::new(connection, session.clone())); let agent = ReActAgent::with_max_turns(ProfessorMariAgent { tools }, 8); @@ -182,7 +187,10 @@ struct MarinaraLlmProvider { impl MarinaraLlmProvider { fn new(connection: marinara_llm::LlmConnection, session: Arc) -> Self { - Self { connection, session } + Self { + connection, + session, + } } async fn complete_chat( @@ -399,6 +407,42 @@ fn map_marinara_tool_calls(values: Vec) -> Vec { .collect() } +fn model_turn_summary(content: &str, tool_calls: &[Value]) -> String { + if !tool_calls.is_empty() { + let names = tool_calls + .iter() + .filter_map(tool_call_name) + .collect::>() + .join(", "); + if names.is_empty() { + format!("Requested {} tool call(s).", tool_calls.len()) + } else { + format!("Requested tool call(s): {names}.") + } + } else if !content.trim().is_empty() { + "Prepared final reply.".to_string() + } else { + "Completed a model turn.".to_string() + } +} + +fn summarize_tool_call_value(value: &Value) -> Value { + let function = value.get("function").unwrap_or(value); + json!({ + "id": value.get("id").or_else(|| value.get("call_id")).cloned().unwrap_or(Value::Null), + "name": tool_call_name(value).unwrap_or("tool").to_string(), + "arguments": function.get("arguments").or_else(|| value.get("arguments")).cloned().unwrap_or_else(|| json!("{}")), + }) +} + +fn tool_call_name(value: &Value) -> Option<&str> { + value + .get("function") + .and_then(|function| function.get("name")) + .or_else(|| value.get("name")) + .and_then(Value::as_str) +} + fn sampling_parameters(sampling: Option<&SamplingOverrides>) -> Value { let mut params = json!({ "temperature": 0.35, @@ -1259,12 +1303,15 @@ struct MariShellSession { bash: Arc>, initial_files: Arc>>>, manifest: Arc>, + trace: Arc>>, + trace_channel: tauri::ipc::Channel, } impl MariShellSession { async fn new( input: &MariPromptRequest, workspace_seed: MariWorkspaceSeed, + trace_channel: tauri::ipc::Channel, ) -> AppResult> { let fs = Arc::new(TrackingFs::new()); fs.add_text_file("/workspace/system-prompt.md", MARI_SYSTEM_PROMPT); @@ -1299,6 +1346,8 @@ impl MariShellSession { bash: Arc::new(Mutex::new(bash)), initial_files: Arc::new(RwLock::new(BTreeMap::new())), manifest: Arc::new(workspace_seed.bindings), + trace: Arc::new(RwLock::new(Vec::new())), + trace_channel, }); let initial = session.snapshot_review_files().await?; *session.initial_files.write().unwrap() = initial; @@ -1359,6 +1408,17 @@ impl MariShellSession { Ok(diff_file_maps(&initial, ¤t)) } + fn record_trace(&self, event: Value) { + self.trace.write().unwrap().push(event.clone()); + let _ = self + .trace_channel + .send(json!({ "type": "trace", "event": event })); + } + + fn trace_events(&self) -> Vec { + self.trace.read().unwrap().clone() + } + fn manifest_summary(&self) -> Value { let mut by_entity: BTreeMap<&str, usize> = BTreeMap::new(); let mut text_field_bindings = 0usize; @@ -1501,13 +1561,44 @@ impl fmt::Debug for PiLikeTool { #[async_trait] impl ToolRuntime for PiLikeTool { async fn execute(&self, args: Value) -> Result { + let started_at = chrono::Utc::now().to_rfc3339(); + let tool_name = self.name().to_string(); + let args_for_trace = summarize_tool_args(&tool_name, &args); let result = match self.kind { PiToolKind::Read => self.tool_read(args).await, PiToolKind::Bash => self.tool_bash(args).await, PiToolKind::Edit => self.tool_edit(args).await, PiToolKind::Write => self.tool_write(args).await, }; - result.map_err(tool_runtime_error) + match result { + Ok(value) => { + self.session.record_trace(json!({ + "type": "tool_result", + "label": tool_label(&tool_name), + "tool": tool_name, + "startedAt": started_at, + "finishedAt": chrono::Utc::now().to_rfc3339(), + "arguments": args_for_trace, + "result": summarize_tool_result(&value), + "status": "success", + })); + Ok(value) + } + Err(error) => { + let message = error.message.clone(); + self.session.record_trace(json!({ + "type": "tool_result", + "label": tool_label(&tool_name), + "tool": tool_name, + "startedAt": started_at, + "finishedAt": chrono::Utc::now().to_rfc3339(), + "arguments": args_for_trace, + "error": message, + "status": "error", + })); + Err(tool_runtime_error(error)) + } + } } } @@ -1600,6 +1691,59 @@ impl PiLikeTool { } } +fn tool_label(name: &str) -> String { + match name { + "read" => "Read file", + "bash" => "Run bash", + "edit" => "Edit file", + "write" => "Write file", + _ => "Use tool", + } + .to_string() +} + +fn summarize_tool_args(tool: &str, args: &Value) -> Value { + match tool { + "read" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "offset": args.get("offset").cloned().unwrap_or(Value::Null), + "limit": args.get("limit").cloned().unwrap_or(Value::Null), + }), + "bash" => json!({ + "command": args.get("command").and_then(Value::as_str).map(truncate_tool_text).unwrap_or_default(), + }), + "edit" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "oldText": args.get("oldText").and_then(Value::as_str).map(truncate_tool_text).unwrap_or_default(), + "newText": args.get("newText").and_then(Value::as_str).map(truncate_tool_text).unwrap_or_default(), + }), + "write" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "content": args.get("content").and_then(Value::as_str).map(truncate_tool_text).unwrap_or_default(), + }), + _ => args.clone(), + } +} + +fn summarize_tool_result(value: &Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, value)| { + let next = match value { + Value::String(text) => Value::String(truncate_tool_text(text)), + _ => value.clone(), + }; + (key.clone(), next) + }) + .collect(), + ), + Value::String(text) => Value::String(truncate_tool_text(text)), + _ => value.clone(), + } +} + fn build_pi_like_tools(session: Arc) -> Vec> { [ PiToolKind::Read, diff --git a/src/engine/mari/mari-entry.ts b/src/engine/mari/mari-entry.ts index e17cbf367..c93416e84 100644 --- a/src/engine/mari/mari-entry.ts +++ b/src/engine/mari/mari-entry.ts @@ -1,8 +1,24 @@ +export type MariTraceEvent = { + type: string; + label?: string; + summary?: string; + tool?: string; + status?: "success" | "error" | string; + startedAt?: string; + finishedAt?: string; + content?: string; + arguments?: unknown; + result?: unknown; + error?: string; + toolCalls?: unknown[]; +}; + export type MariMessage = { id: string; role: "user" | "assistant"; content: string; createdAt: string; + trace?: MariTraceEvent[]; }; export type MariAttachment = { @@ -81,10 +97,12 @@ export type MariEntryResponse = { content: string; createdAt: string; action: MariEntryAction; + trace: MariTraceEvent[]; }; -export type MariGatewayResponse = Omit & { +export type MariGatewayResponse = Omit & { action?: unknown; + trace?: unknown; }; export type MariGateway = { @@ -103,9 +121,28 @@ export async function runProfessorMariEntry(input: MariEntryRequest, gateway: Ma return { ...response, action: normalizeMariEntryAction(response.action), + trace: normalizeMariTrace(response.trace), }; } +function normalizeMariTrace(value: unknown): MariTraceEvent[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).map((event) => ({ + type: typeof event.type === "string" ? event.type : "event", + ...(typeof event.label === "string" ? { label: event.label } : {}), + ...(typeof event.summary === "string" ? { summary: event.summary } : {}), + ...(typeof event.tool === "string" ? { tool: event.tool } : {}), + ...(typeof event.status === "string" ? { status: event.status } : {}), + ...(typeof event.startedAt === "string" ? { startedAt: event.startedAt } : {}), + ...(typeof event.finishedAt === "string" ? { finishedAt: event.finishedAt } : {}), + ...(typeof event.content === "string" ? { content: event.content } : {}), + ...("arguments" in event ? { arguments: event.arguments } : {}), + ...("result" in event ? { result: event.result } : {}), + ...(typeof event.error === "string" ? { error: event.error } : {}), + ...(Array.isArray(event.toolCalls) ? { toolCalls: event.toolCalls } : {}), + })); +} + function normalizeMariEntryAction(value: unknown): MariEntryAction { if (!isRecord(value)) return MARI_READ_ONLY_ACTION; if (value.type === "none" && value.capability === "read_only") { diff --git a/src/features/shell/mari/components/ProfessorMariSurface.tsx b/src/features/shell/mari/components/ProfessorMariSurface.tsx index cdaab2cdb..023f08724 100644 --- a/src/features/shell/mari/components/ProfessorMariSurface.tsx +++ b/src/features/shell/mari/components/ProfessorMariSurface.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Check, ChevronUp, CircleUser, FileText, Link, Plus, Send, X } from "lucide-react"; -import { runProfessorMariEntry, type MariMessage } from "../../../../engine/mari/mari-entry"; +import { Activity, AlertTriangle, Check, CheckCircle2, ChevronUp, CircleUser, FileText, Link, Plus, Send, Terminal, X } from "lucide-react"; +import { runProfessorMariEntry, type MariMessage, type MariTraceEvent } from "../../../../engine/mari/mari-entry"; import { mariApi } from "../../../../shared/api/mari-api"; import { useConnections } from "../../../catalog/connections/index"; import { usePersonas } from "../../../catalog/characters/index"; @@ -107,6 +107,7 @@ export function ProfessorMariSurface() { const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [sendErrorDetails, setSendErrorDetails] = useState(null); + const [liveTrace, setLiveTrace] = useState([]); const fileInputRef = useRef(null); const inputRef = useRef(null); const messagesEndRef = useRef(null); @@ -215,6 +216,7 @@ export function ProfessorMariSurface() { setAttachments([]); setSendError(null); setSendErrorDetails(null); + setLiveTrace([]); setSending(true); requestAnimationFrame(() => inputRef.current?.focus()); let response; @@ -244,7 +246,14 @@ export function ProfessorMariSurface() { content: attachment.content, })), }, - mariApi, + { + prompt: (request) => + mariApi.prompt(request, (event) => { + if (event.type === "trace") { + setLiveTrace((current) => [...current, event.event]); + } + }), + }, ); } catch (error) { console.error("Professor Mari failed to respond", error); @@ -258,8 +267,10 @@ export function ProfessorMariSurface() { role: "assistant", content: response.content, createdAt: response.createdAt, + trace: response.trace, }; setMessages((current) => [...current, assistant]); + setLiveTrace([]); setSending(false); requestAnimationFrame(() => inputRef.current?.focus()); }; @@ -320,13 +331,14 @@ export function ProfessorMariSurface() { messageIndex={index + 1} messageOrderIndex={index} /> + {messages[index]?.role === "assistant" && messages[index]?.trace?.length ? ( + + ) : null} ); }) )} - {sending && ( -
Professor Mari is thinking...
- )} + {sending && } {sendError && (
{sendError}
@@ -507,6 +519,108 @@ export function ProfessorMariSurface() { ); } +function MariWorkingTrace({ events }: { events: MariTraceEvent[] }) { + const latest = events.at(-1); + return ( +
+
+ + Professor Mari is working + {events.length > 0 && ( + + {events.length} live {events.length === 1 ? "event" : "events"} + + )} +
+ {latest ? ( +
+ + {events.length > 1 && ( +
+ + Show previous live steps + +
+ {events.slice(0, -1).map((event, index) => ( + + ))} +
+
+ )} +
+ ) : ( +

+ Building the virtual workspace, asking the model, and waiting for the first tool step. +

+ )} +
+ ); +} + +function MariTracePanel({ events }: { events: MariTraceEvent[] }) { + if (!events.length) return null; + return ( +
+ + + Agent activity + + {events.length} {events.length === 1 ? "event" : "events"} + + +
+ {events.map((event, index) => ( + + ))} +
+
+ ); +} + +function MariTraceEventItem({ event }: { event: MariTraceEvent }) { + const isError = event.status === "error"; + const Icon = event.type === "tool_result" ? Terminal : isError ? AlertTriangle : CheckCircle2; + const details = traceDetails(event); + return ( +
+
+ +
+
+ {event.label || event.tool || event.type} + {event.status && ( + + {event.status} + + )} +
+ {event.summary &&

{event.summary}

} + {details && ( +
+              {details}
+            
+ )} +
+
+
+ ); +} + +function traceDetails(event: MariTraceEvent) { + const payload: Record = {}; + if (event.content?.trim()) payload.content = event.content; + if (event.toolCalls?.length) payload.toolCalls = event.toolCalls; + if (event.arguments !== undefined) payload.arguments = event.arguments; + if (event.result !== undefined) payload.result = event.result; + if (event.error) payload.error = event.error; + if (Object.keys(payload).length === 0) return null; + try { + return JSON.stringify(payload, null, 2); + } catch { + return String(payload); + } +} + function MariContextMenu({ connections, personas, diff --git a/src/shared/api/mari-api.ts b/src/shared/api/mari-api.ts index a4897ade3..6d8dc4201 100644 --- a/src/shared/api/mari-api.ts +++ b/src/shared/api/mari-api.ts @@ -1,9 +1,15 @@ -import type { MariEntryRequest, MariGatewayResponse } from "../../engine/mari/mari-entry"; +import type { MariEntryRequest, MariGatewayResponse, MariTraceEvent } from "../../engine/mari/mari-entry"; +import { Channel } from "@tauri-apps/api/core"; import { invokeTauri } from "./tauri-client"; +export type MariStreamEvent = { type: "trace"; event: MariTraceEvent }; + export const mariApi = { - prompt: (request: MariEntryRequest) => - invokeTauri("professor_mari_prompt", { + prompt: (request: MariEntryRequest, onEvent: (event: MariStreamEvent) => void = () => undefined) => { + const channel = new Channel(onEvent); + return invokeTauri("professor_mari_prompt", { request, - }), + onEvent: channel, + }); + }, }; From ff24501cf547e23dbab89b959f0552e36ed8fefa Mon Sep 17 00:00:00 2001 From: munimunigamer Date: Mon, 25 May 2026 19:30:22 -0500 Subject: [PATCH 6/8] updated Mari UI (good enough) --- .../mari/components/ProfessorMariSurface.tsx | 1029 ++++++++++------- 1 file changed, 610 insertions(+), 419 deletions(-) diff --git a/src/features/shell/mari/components/ProfessorMariSurface.tsx b/src/features/shell/mari/components/ProfessorMariSurface.tsx index 023f08724..6934933e5 100644 --- a/src/features/shell/mari/components/ProfessorMariSurface.tsx +++ b/src/features/shell/mari/components/ProfessorMariSurface.tsx @@ -1,18 +1,30 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Activity, AlertTriangle, Check, CheckCircle2, ChevronUp, CircleUser, FileText, Link, Plus, Send, Terminal, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { + AlertTriangle, + Check, + ChevronUp, + CircleUser, + FileText, + Link, + Paperclip, + Send, + Sparkles, + Terminal, + Wrench, + X, +} from "lucide-react"; import { runProfessorMariEntry, type MariMessage, type MariTraceEvent } from "../../../../engine/mari/mari-entry"; import { mariApi } from "../../../../shared/api/mari-api"; import { useConnections } from "../../../catalog/connections/index"; import { usePersonas } from "../../../catalog/characters/index"; -import { ConversationMessage } from "../../../modes/conversation/index"; -import type { CharacterMap, PersonaInfo } from "../../../modes/shared/chat-ui/types"; -import type { Message } from "../../../../engine/contracts/types/chat"; import { filterLanguageGenerationConnections } from "../../../../shared/lib/connection-filters"; import { cn, getAvatarCropStyle, parseAvatarCropJson } from "../../../../shared/lib/utils"; import { useUIStore } from "../../../../shared/stores/ui.store"; const MARI_AVATAR_URL = "/sprites/mari/Mari_profile.png"; -const MARI_CHARACTER_ID = "__professor_mari_shell__"; +const MARI_THINKING_URL = "/sprites/mari/Mari_thinking.png"; +const MARI_WAVE_URL = "/sprites/mari/Mari_wave.png"; +const MARI_WORKING_URL = "/sprites/mari/Mari_point_down_left.png"; type MariAttachment = { id: string; @@ -41,6 +53,8 @@ type MariPersona = { appearance?: string | null; }; +type MariOptionPanel = "connections" | "personas"; + function newId(prefix: string) { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } @@ -61,23 +75,9 @@ function getDayKey(value: string) { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } -function toConversationMessage(message: MariMessage): Message { - return { - id: message.id, - chatId: "professor-mari", - role: message.role, - characterId: message.role === "assistant" ? MARI_CHARACTER_ID : null, - content: message.content, - activeSwipeIndex: 0, - swipeCount: 1, - createdAt: message.createdAt, - extra: { - displayText: null, - isGenerated: message.role === "assistant", - tokenCount: null, - generationInfo: null, - }, - }; +function formatTime(value: string) { + const date = new Date(value); + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } function formatErrorDetails(error: unknown) { @@ -101,9 +101,7 @@ export function ProfessorMariSurface() { const [attachments, setAttachments] = useState([]); const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [selectedPersonaId, setSelectedPersonaId] = useState(null); - const [connectionMenuOpen, setConnectionMenuOpen] = useState(false); - const [personaMenuOpen, setPersonaMenuOpen] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [optionPanel, setOptionPanel] = useState(null); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [sendErrorDetails, setSendErrorDetails] = useState(null); @@ -111,6 +109,9 @@ export function ProfessorMariSurface() { const fileInputRef = useRef(null); const inputRef = useRef(null); const messagesEndRef = useRef(null); + const surfaceRef = useRef(null); + const spriteMeasureRef = useRef(null); + const [spriteSafeInset, setSpriteSafeInset] = useState(0); const canSend = (draft.trim().length > 0 || attachments.length > 0) && !sending; const connections = useMemo( () => @@ -125,49 +126,72 @@ export function ProfessorMariSurface() { ); const selectedConnection = connections.find((connection) => connection.id === selectedConnectionId) ?? null; const selectedPersona = personas.find((persona) => persona.id === selectedPersonaId) ?? null; + const hasToolActivity = liveTrace.some((event) => event.type === "tool_result" || !!event.tool || (Array.isArray(event.toolCalls) && event.toolCalls.length > 0)); + const mariStage = sendError + ? { src: MARI_THINKING_URL, mood: "thinking" as const } + : sending + ? hasToolActivity + ? { src: MARI_WORKING_URL, mood: "working" as const } + : { src: MARI_THINKING_URL, mood: "thinking" as const } + : { src: MARI_WAVE_URL, mood: "idle" as const }; const gradientStyle = useMemo(() => { const gradient = convoGradient[theme]; const isDefaultDark = convoGradient.dark.from === "#0a0a0e" && convoGradient.dark.to === "#1c2133"; const isDefaultLight = convoGradient.light.from === "#f2eff7" && convoGradient.light.to === "#eae6f0"; if ((theme === "dark" && isDefaultDark) || (theme === "light" && isDefaultLight)) { - return { background: "var(--secondary)" }; + return { + background: + "radial-gradient(circle at 20% 0%, color-mix(in oklab, var(--primary) 12%, transparent), transparent 22rem), var(--secondary)", + }; } - return { background: `linear-gradient(135deg, ${gradient.from}, ${gradient.to})` }; + return { + background: `radial-gradient(circle at 20% 0%, color-mix(in oklab, var(--primary) 14%, transparent), transparent 22rem), linear-gradient(135deg, ${gradient.from}, ${gradient.to})`, + }; }, [convoGradient, theme]); - const characterMap: CharacterMap = useMemo( - () => - new Map([ - [ - MARI_CHARACTER_ID, - { - name: "Professor Mari", - avatarUrl: MARI_AVATAR_URL, - conversationStatus: "online", - }, - ], - ]), - [], - ); - const personaInfo: PersonaInfo | undefined = useMemo(() => { - if (!selectedPersona) return undefined; + const surfaceStyle = useMemo(() => { + const bubbleOverlap = Math.min(spriteSafeInset * 0.28, 48); return { - name: selectedPersona.name, - description: selectedPersona.description ?? undefined, - avatarUrl: selectedPersona.avatarPath ?? undefined, - avatarCrop: parseAvatarCropJson(selectedPersona.avatarCrop), + ...gradientStyle, + "--mari-sprite-safe": `${spriteSafeInset}px`, + "--mari-chat-gutter": `${Math.max(0, spriteSafeInset - bubbleOverlap)}px`, + "--mari-bubble-overlap": `${bubbleOverlap}px`, + } as CSSProperties; + }, [gradientStyle, spriteSafeInset]); + + useEffect(() => { + const updateSpriteSafeInset = () => { + const surfaceWidth = surfaceRef.current?.getBoundingClientRect().width ?? window.innerWidth; + const spriteWidth = spriteMeasureRef.current?.getBoundingClientRect().width ?? 0; + const roomFactor = Math.max(0, Math.min(1, (surfaceWidth - 520) / 320)); + const visualOverlap = Math.min(spriteWidth * 0.22, 56); + const nextInset = Math.max(0, Math.round((spriteWidth - visualOverlap) * roomFactor)); + setSpriteSafeInset((current) => (Math.abs(current - nextInset) > 1 ? nextInset : current)); + }; + + updateSpriteSafeInset(); + window.addEventListener("resize", updateSpriteSafeInset); + const observers: ResizeObserver[] = []; + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(updateSpriteSafeInset); + if (surfaceRef.current) observer.observe(surfaceRef.current); + if (spriteMeasureRef.current) observer.observe(spriteMeasureRef.current); + observers.push(observer); + } + return () => { + window.removeEventListener("resize", updateSpriteSafeInset); + observers.forEach((observer) => observer.disconnect()); }; - }, [selectedPersona]); - const conversationMessages = useMemo(() => messages.map(toConversationMessage), [messages]); + }, [mariStage.src]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }, [messages.length]); + }, [messages.length, liveTrace.length]); useEffect(() => { const input = inputRef.current; if (!input) return; input.style.height = "0px"; - input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + input.style.height = `${Math.min(input.scrollHeight, 148)}px`; }, [draft]); const readFiles = async (files: FileList | null) => { @@ -218,6 +242,7 @@ export function ProfessorMariSurface() { setSendErrorDetails(null); setLiveTrace([]); setSending(true); + setOptionPanel(null); requestAnimationFrame(() => inputRef.current?.focus()); let response; try { @@ -276,212 +301,67 @@ export function ProfessorMariSurface() { }; return ( -
-
-
-
-
- - - - -
- Professor Mari -
-
-
+
+
+
+ -
- {messages.length === 0 ? ( -
-

- This is the start of your conversation with{" "} - Professor Mari. -

-
- ) : ( - conversationMessages.map((message, index) => { - const previous = conversationMessages[index - 1]; - const showSeparator = !previous || getDayKey(previous.createdAt) !== getDayKey(message.createdAt); - const isGrouped = - !!previous && - previous.role === message.role && - previous.characterId === message.characterId && - getDayKey(previous.createdAt) === getDayKey(message.createdAt) && - new Date(message.createdAt).getTime() - new Date(previous.createdAt).getTime() <= 5 * 60 * 1000; - return ( -
- {showSeparator && ( -
-
- - {formatDaySeparator(message.createdAt)} - -
-
- )} - - {messages[index]?.role === "assistant" && messages[index]?.trace?.length ? ( - - ) : null} -
- ); - }) - )} - {sending && } - {sendError && ( -
-
{sendError}
- {sendErrorDetails && ( -
- Debug details -
{sendErrorDetails}
-
- )} -
- )} -
-
+
+
+
+ + {sending && } + {sendError && } +
+
+
-
- {(connectionMenuOpen || personaMenuOpen || mobileMenuOpen) && ( - + void readFiles(event.target.files)} + /> + + {optionPanel && ( + { setSelectedConnectionId(id); - setConnectionMenuOpen(false); - setMobileMenuOpen(false); + setOptionPanel(null); }} onSelectPersona={(id) => { setSelectedPersonaId(id); - setPersonaMenuOpen(false); - setMobileMenuOpen(false); + setOptionPanel(null); }} /> )} - {attachments.length > 0 && ( -
- {attachments.map((attachment) => ( -
- - {attachment.name} - -
- ))} -
- )} + setAttachments((current) => current.filter((item) => item.id !== id))} /> -
- void readFiles(event.target.files)} - /> - - - - - - - +
+
+ fileInputRef.current?.click()} /> + setOptionPanel((current) => (current === "connections" ? null : "connections"))} + /> + setOptionPanel((current) => (current === "personas" ? null : "personas"))} + /> +