From 16e5ac543c498e148e39dc9d8da300f8393357fe Mon Sep 17 00:00:00 2001 From: sanil jain Date: Tue, 31 Mar 2026 14:01:51 +0530 Subject: [PATCH 1/2] Add core memory RPC surface --- Cargo.lock | 1129 ++++++++++++++- Cargo.toml | 1 + src/core/jsonrpc.rs | 44 + src/openhuman/config/schema/storage_memory.rs | 6 +- src/openhuman/memory/embeddings.rs | 125 ++ src/openhuman/memory/mod.rs | 6 +- src/openhuman/memory/ops.rs | 783 +++++++++- src/openhuman/memory/rpc_models.rs | 337 +++++ src/openhuman/memory/store/client.rs | 40 +- src/openhuman/memory/store/memory_trait.rs | 2 +- src/openhuman/memory/store/mod.rs | 6 +- src/openhuman/memory/store/types.rs | 85 ++ .../memory/store/unified/documents.rs | 122 +- src/openhuman/memory/store/unified/graph.rs | 451 +++++- src/openhuman/memory/store/unified/helpers.rs | 129 +- src/openhuman/memory/store/unified/init.rs | 9 + src/openhuman/memory/store/unified/kv.rs | 74 + src/openhuman/memory/store/unified/query.rs | 1281 +++++++++++++++-- src/rpc/dispatch.rs | 86 ++ 19 files changed, 4432 insertions(+), 284 deletions(-) create mode 100644 src/openhuman/memory/rpc_models.rs diff --git a/Cargo.lock b/Cargo.lock index 1ee57c74b..f4cccd48b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -85,6 +87,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -185,12 +205,29 @@ dependencies = [ "object 0.37.3", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "archery" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "argon2" version = "0.5.3" @@ -218,6 +255,15 @@ dependencies = [ "serde", ] +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "as_variant" version = "1.3.0" @@ -273,7 +319,7 @@ checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66" dependencies = [ "async-channel 2.5.0", "async-compression", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -362,6 +408,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.16.2" @@ -392,7 +481,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -460,6 +549,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -492,6 +587,12 @@ dependencies = [ "virtue", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitfield" version = "0.19.4" @@ -533,6 +634,15 @@ version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -595,6 +705,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" @@ -633,6 +749,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -648,6 +770,15 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[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" @@ -844,12 +975,33 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -876,6 +1028,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.3" @@ -974,6 +1139,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -1030,6 +1204,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1045,6 +1229,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1149,6 +1339,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1264,6 +1463,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1284,6 +1493,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -1332,7 +1572,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console", + "console 0.16.3", "fuzzy-matcher", "shell-words", "tempfile", @@ -1500,7 +1740,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -1537,7 +1777,7 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b48069eef4227bde0e5a8e0601ddabfbdef887c05b831cd314d2990726a461" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1601,6 +1841,26 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1623,6 +1883,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "esp-idf-part" version = "0.6.0" @@ -1647,7 +1913,7 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46f05d15cb2479a3cbbbe684b9f0831b2ae036d9faefd1eb08f21267275862f9" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.11.0", "bytemuck", "esp-idf-part", @@ -1701,6 +1967,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "eyeball" version = "0.8.8" @@ -1763,7 +2044,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7737298823a6f9ca743e372e8cb03658d55354fbab843424f575706ba9563046" dependencies = [ - "base64", + "base64 0.22.1", "cookie 0.18.1", "http 1.4.0", "http-body-util", @@ -1779,12 +2060,49 @@ dependencies = [ "webdriver", ] +[[package]] +name = "fastembed" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fd-lock" version = "4.0.4" @@ -1796,6 +2114,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1837,6 +2164,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2051,6 +2384,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2124,6 +2467,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2149,7 +2503,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2157,6 +2511,13 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashify" @@ -2184,7 +2545,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http 1.4.0", @@ -2236,6 +2597,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs 6.0.0", + "http 1.4.0", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.2", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] + [[package]] name = "hidapi" version = "2.6.5" @@ -2269,6 +2651,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "home" version = "0.5.12" @@ -2429,7 +2817,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2441,9 +2829,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2664,6 +3054,40 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "imap-proto" version = "0.16.6" @@ -2697,6 +3121,12 @@ dependencies = [ "bitmaps", ] +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "impartial-ord" version = "1.0.6" @@ -2739,6 +3169,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console 0.15.11", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2749,6 +3192,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -2946,13 +3400,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "lettre" version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" dependencies = [ - "base64", + "base64 0.22.1", "chumsky", "email-encoding", "email_address", @@ -2976,6 +3436,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -3057,6 +3527,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lopdf" version = "0.38.0" @@ -3091,6 +3570,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" + [[package]] name = "mac" version = "0.1.1" @@ -3115,6 +3600,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "macroific" version = "2.0.0" @@ -3394,7 +3895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6096084cc8d339c03e269ca25534d0f1e88d0097c35a215eb8c311797ec3e9" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "futures-util", "getrandom 0.2.17", "gloo-utils", @@ -3453,7 +3954,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162a93e83114d5cef25c0ebaea72aa01b9f233df6ec4a2af45f175d01ec26323" dependencies = [ - "base64", + "base64 0.22.1", "blake3", "chacha20poly1305", "getrandom 0.2.17", @@ -3507,6 +4008,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3634,6 +4155,38 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3657,6 +4210,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3746,6 +4314,12 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3765,12 +4339,62 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3790,6 +4414,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "nusb" version = "0.1.14" @@ -3833,7 +4463,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -3897,6 +4527,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.11.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3913,19 +4565,20 @@ dependencies = [ "async-imap", "async-trait", "axum", - "base64", + "base64 0.22.1", "chacha20poly1305", "chrono", "chrono-tz", "clap", "clap_complete", - "console", + "console 0.16.3", "cron", "dialoguer", "directories", "dirs 5.0.1", "env_logger", "fantoccini", + "fastembed", "futures", "futures-util", "glob", @@ -4123,6 +4776,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq 3.3.0", +] + [[package]] name = "overload" version = "0.1.1" @@ -4178,6 +4855,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -4205,6 +4894,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4355,7 +5053,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -4365,6 +5063,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4443,7 +5154,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator 0.2.0", @@ -4555,7 +5266,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2239aca5dc62c68ca6d8ff0051fe617cb8363b803380fbc60567e67c82b474df" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "jep106", "serde", @@ -4602,6 +5313,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "prometheus" version = "0.14.0" @@ -4736,6 +5466,27 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -4925,6 +5676,93 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "readlock" version = "0.1.11" @@ -5026,8 +5864,9 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -5041,6 +5880,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -5067,6 +5907,12 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5185,7 +6031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a01993f22d291320b7c9267675e7395775e95269ff526e2c8c3ed5e13175b" dependencies = [ "as_variant", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "getrandom 0.2.17", @@ -5303,7 +6149,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146ace2cd59b60ec80d3e801a84e7e6a91e3e01d18a9f5d896ea7ca16a6b8e08" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "pkcs8", "rand 0.8.5", @@ -5461,6 +6307,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "schannel" version = "0.1.29" @@ -5697,7 +6554,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap", @@ -5814,6 +6671,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -5901,6 +6767,17 @@ dependencies = [ "socketioxide-core", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spki" version = "0.7.3" @@ -5908,7 +6785,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", ] [[package]] @@ -5930,6 +6819,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stop-token" version = "0.7.0" @@ -6053,6 +6948,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -6138,6 +7054,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6203,6 +7133,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.50.0" @@ -6351,7 +7314,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -6446,7 +7409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "http 1.4.0", "http-body", @@ -6751,6 +7714,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -6781,6 +7753,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" @@ -6809,22 +7787,46 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "ureq" version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "cookie_store", + "der 0.8.0", "log", + "native-tls", "percent-encoding", "rustls", "rustls-pki-types", "serde", "serde_json", + "socks", "ureq-proto", "utf8-zero", + "webpki-root-certs", "webpki-roots 1.0.6", ] @@ -6834,7 +7836,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.0", "httparse", "log", @@ -6895,6 +7897,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6927,7 +7940,7 @@ checksum = "c022a277687e4e8685d72b95a7ca3ccfec907daa946678e715f8badaa650883d" dependencies = [ "aes", "arrayvec", - "base64", + "base64 0.22.1", "base64ct", "cbc", "chacha20poly1305", @@ -6958,7 +7971,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "dashmap", @@ -7025,7 +8038,7 @@ dependencies = [ "anyhow", "async-channel 2.5.0", "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "ctr", @@ -7162,7 +8175,7 @@ dependencies = [ "anyhow", "async-trait", "tokio", - "ureq", + "ureq 3.3.0", "wa-rs-core", ] @@ -7380,7 +8393,7 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91d53921e1bef27512fa358179c9a22428d55778d2c2ae3c5c37a52b82ce6e92" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie 0.16.2", "http 0.2.12", @@ -7394,6 +8407,15 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -7500,6 +8522,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -7891,6 +8924,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.7.5" @@ -8065,3 +9104,27 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index cc81b3a32..0a6b7dd28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" hex = "0.4" +fastembed = "5.13" tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures = "0.3" diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 1dda96488..c8ce4b5b1 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -476,6 +476,50 @@ mod tests { assert!(err.contains("unknown param 'x'")); } + #[tokio::test] + async fn invoke_memory_init_missing_required_param_fails() { + let err = invoke_method(default_state(), "memory.init", json!({})) + .await + .expect_err("missing jwt_token should fail"); + assert!(err.contains("missing field `jwt_token`") || err.contains("jwt_token")); + } + + #[tokio::test] + async fn invoke_memory_list_namespaces_rejects_unknown_param() { + let err = invoke_method( + default_state(), + "memory.list_namespaces", + json!({ "extra": true }), + ) + .await + .expect_err("unknown param should fail"); + assert!(err.contains("unknown field `extra`") || err.contains("extra")); + } + + #[tokio::test] + async fn invoke_memory_query_namespace_missing_namespace_fails() { + let err = invoke_method( + default_state(), + "memory.query_namespace", + json!({ "query": "who owns atlas" }), + ) + .await + .expect_err("missing namespace should fail"); + assert!(err.contains("missing field `namespace`") || err.contains("namespace")); + } + + #[tokio::test] + async fn invoke_memory_recall_memories_rejects_unknown_param() { + let err = invoke_method( + default_state(), + "memory.recall_memories", + json!({ "namespace": "team", "extra": true }), + ) + .await + .expect_err("unknown param should fail"); + assert!(err.contains("unknown field `extra`") || err.contains("extra")); + } + #[tokio::test] async fn invoke_migrate_openclaw_rejects_unknown_param() { let err = invoke_method( diff --git a/src/openhuman/config/schema/storage_memory.rs b/src/openhuman/config/schema/storage_memory.rs index 53585e33b..912e44758 100644 --- a/src/openhuman/config/schema/storage_memory.rs +++ b/src/openhuman/config/schema/storage_memory.rs @@ -109,7 +109,7 @@ fn default_true() -> bool { } fn default_embedding_provider() -> String { - "none".into() + "fastembed".into() } fn default_hygiene_enabled() -> bool { true @@ -124,10 +124,10 @@ fn default_conversation_retention_days() -> u32 { 30 } fn default_embedding_model() -> String { - "text-embedding-3-small".into() + "BGESmallENV15".into() } fn default_embedding_dims() -> usize { - 1536 + 384 } fn default_vector_weight() -> f64 { 0.7 diff --git a/src/openhuman/memory/embeddings.rs b/src/openhuman/memory/embeddings.rs index d534bbd2f..bbbdf256f 100644 --- a/src/openhuman/memory/embeddings.rs +++ b/src/openhuman/memory/embeddings.rs @@ -1,4 +1,10 @@ use async_trait::async_trait; +use parking_lot::Mutex; +use std::str::FromStr; +use std::sync::Arc; + +pub const DEFAULT_FASTEMBED_MODEL: &str = "BGESmallENV15"; +pub const DEFAULT_FASTEMBED_DIMENSIONS: usize = 384; /// Trait for embedding providers — convert text to vectors #[async_trait] @@ -40,6 +46,103 @@ impl EmbeddingProvider for NoopEmbedding { } } +enum FastembedState { + Uninitialized, + Ready(fastembed::TextEmbedding), + Failed(String), +} + +pub struct FastembedEmbedding { + model: String, + dims: usize, + state: Arc>, +} + +impl FastembedEmbedding { + pub fn new(model: &str, dims: usize) -> Self { + Self { + model: if model.trim().is_empty() { + DEFAULT_FASTEMBED_MODEL.to_string() + } else { + model.trim().to_string() + }, + dims: if dims == 0 { + DEFAULT_FASTEMBED_DIMENSIONS + } else { + dims + }, + state: Arc::new(Mutex::new(FastembedState::Uninitialized)), + } + } + + fn resolve_model(&self) -> fastembed::EmbeddingModel { + fastembed::EmbeddingModel::from_str(&self.model) + .unwrap_or(fastembed::EmbeddingModel::BGESmallENV15) + } + + fn init_model(&self) -> anyhow::Result { + fastembed::TextEmbedding::try_new( + fastembed::InitOptions::new(self.resolve_model()).with_show_download_progress(false), + ) + .map_err(|e| anyhow::anyhow!("fastembed init failed for {}: {e}", self.model)) + } +} + +#[async_trait] +impl EmbeddingProvider for FastembedEmbedding { + fn name(&self) -> &str { + "fastembed" + } + + fn dimensions(&self) -> usize { + self.dims + } + + async fn embed(&self, texts: &[&str]) -> anyhow::Result>> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + let items = texts + .iter() + .map(|text| (*text).to_string()) + .collect::>(); + let state = Arc::clone(&self.state); + let provider = self.model.clone(); + let join_result = tokio::task::spawn_blocking(move || { + let mut guard = state.lock(); + if matches!(*guard, FastembedState::Uninitialized) { + match fastembed::TextEmbedding::try_new( + fastembed::InitOptions::new( + fastembed::EmbeddingModel::from_str(&provider) + .unwrap_or(fastembed::EmbeddingModel::BGESmallENV15), + ) + .with_show_download_progress(false), + ) { + Ok(model) => *guard = FastembedState::Ready(model), + Err(err) => { + let message = format!("fastembed init failed for {provider}: {err}"); + *guard = FastembedState::Failed(message.clone()); + } + } + } + + match &mut *guard { + FastembedState::Ready(model) => model + .embed(items, None) + .map_err(|e| anyhow::anyhow!("fastembed embed failed: {e}")), + FastembedState::Failed(message) => Err(anyhow::anyhow!(message.clone())), + FastembedState::Uninitialized => { + Err(anyhow::anyhow!("fastembed provider did not initialize")) + } + } + }) + .await; + + join_result.map_err(|e| anyhow::anyhow!("fastembed task join failed: {e}"))? + } +} + // ── OpenAI-compatible embedding provider ───────────────────── pub struct OpenAiEmbedding { @@ -163,6 +266,7 @@ pub fn create_embedding_provider( dims: usize, ) -> Box { match provider { + "fastembed" => Box::new(FastembedEmbedding::new(model, dims)), "openai" => { let key = api_key.unwrap_or(""); Box::new(OpenAiEmbedding::new( @@ -181,6 +285,13 @@ pub fn create_embedding_provider( } } +pub fn default_local_embedding_provider() -> Arc { + Arc::new(FastembedEmbedding::new( + DEFAULT_FASTEMBED_MODEL, + DEFAULT_FASTEMBED_DIMENSIONS, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -212,6 +323,13 @@ mod tests { assert_eq!(p.dimensions(), 1536); } + #[test] + fn factory_fastembed() { + let p = create_embedding_provider("fastembed", None, DEFAULT_FASTEMBED_MODEL, 384); + assert_eq!(p.name(), "fastembed"); + assert_eq!(p.dimensions(), 384); + } + #[test] fn factory_custom_url() { let p = create_embedding_provider("custom:http://localhost:1234", None, "model", 768); @@ -255,6 +373,13 @@ mod tests { assert_eq!(p.name(), "none"); } + #[test] + fn default_local_provider_uses_fastembed_defaults() { + let p = default_local_embedding_provider(); + assert_eq!(p.name(), "fastembed"); + assert_eq!(p.dimensions(), DEFAULT_FASTEMBED_DIMENSIONS); + } + #[test] fn factory_custom_empty_url() { // "custom:" with no URL — should still construct without panic diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index 0a759bf16..6e00af989 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -1,13 +1,17 @@ +pub mod chunker; pub mod embeddings; pub mod ops; +pub mod rpc_models; pub mod store; pub mod traits; pub use ops as rpc; pub use ops::*; +pub use rpc_models::*; pub use store::{ create_memory, create_memory_for_migration, create_memory_with_storage, create_memory_with_storage_and_routes, effective_memory_backend_name, MemoryClient, - MemoryClientRef, MemoryState, NamespaceDocumentInput, NamespaceQueryResult, UnifiedMemory, + MemoryClientRef, MemoryItemKind, MemoryState, NamespaceDocumentInput, NamespaceMemoryHit, + NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, UnifiedMemory, }; pub use traits::{Memory, MemoryCategory, MemoryEntry}; diff --git a/src/openhuman/memory/ops.rs b/src/openhuman/memory/ops.rs index 6c6fcb177..379795531 100644 --- a/src/openhuman/memory/ops.rs +++ b/src/openhuman/memory/ops.rs @@ -1,6 +1,431 @@ -use crate::openhuman::memory::{MemoryClient, NamespaceDocumentInput}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::openhuman::config::Config; +use crate::openhuman::memory::store::GraphRelationRecord; +use crate::openhuman::memory::{ + ApiEnvelope, ApiError, ApiMeta, DeleteDocumentRequest, DeleteDocumentResponse, EmptyRequest, + ListDocumentsRequest, ListDocumentsResponse, ListMemoryFilesRequest, ListMemoryFilesResponse, + ListNamespacesResponse, MemoryClient, MemoryClientRef, MemoryDocumentSummary, + MemoryInitRequest, MemoryInitResponse, MemoryItemKind, MemoryRecallItem, MemoryRetrievalChunk, + MemoryRetrievalContext, MemoryRetrievalEntity, MemoryRetrievalRelation, NamespaceDocumentInput, + NamespaceMemoryHit, NamespaceRetrievalContext, PaginationMeta, QueryNamespaceRequest, + QueryNamespaceResponse, ReadMemoryFileRequest, ReadMemoryFileResponse, RecallContextRequest, + RecallContextResponse, RecallMemoriesRequest, RecallMemoriesResponse, WriteMemoryFileRequest, + WriteMemoryFileResponse, +}; use crate::rpc::RpcOutcome; +use chrono::TimeZone; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +static MEMORY_CLIENT_STATE: OnceLock>> = OnceLock::new(); + +fn memory_client_state() -> &'static Mutex> { + MEMORY_CLIENT_STATE.get_or_init(|| Mutex::new(None)) +} + +fn lock_memory_client_state( +) -> Result>, String> { + memory_client_state() + .lock() + .map_err(|_| "memory client state lock poisoned".to_string()) +} + +fn memory_request_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +fn memory_counts( + entries: impl IntoIterator, +) -> BTreeMap { + entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect() +} + +fn envelope( + data: T, + counts: Option>, + pagination: Option, +) -> RpcOutcome> { + RpcOutcome::new( + ApiEnvelope { + data: Some(data), + error: None, + meta: ApiMeta { + request_id: memory_request_id(), + latency_seconds: None, + cached: None, + counts, + pagination, + }, + }, + vec![], + ) +} + +fn error_envelope(code: &str, message: String) -> RpcOutcome> { + RpcOutcome::new( + ApiEnvelope { + data: None, + error: Some(ApiError { + code: code.to_string(), + message, + details: None, + }), + meta: ApiMeta { + request_id: memory_request_id(), + latency_seconds: None, + cached: None, + counts: None, + pagination: None, + }, + }, + vec![], + ) +} + +fn timestamp_to_rfc3339(timestamp: f64) -> Option { + if !timestamp.is_finite() || timestamp < 0.0 { + return None; + } + + let secs = timestamp.trunc() as i64; + let nanos = ((timestamp.fract().abs()) * 1_000_000_000.0).round() as u32; + chrono::Utc + .timestamp_opt(secs, nanos.min(999_999_999)) + .single() + .map(|value| value.to_rfc3339()) +} + +fn memory_kind_label(kind: &MemoryItemKind) -> &'static str { + match kind { + MemoryItemKind::Document => "document", + MemoryItemKind::Kv => "kv", + } +} + +fn relation_identity(relation: &GraphRelationRecord) -> String { + format!( + "{}|{}|{}|{}", + relation.namespace.as_deref().unwrap_or("global"), + relation.subject.as_str(), + relation.predicate.as_str(), + relation.object.as_str() + ) +} + +fn relation_metadata(relation: &GraphRelationRecord) -> Value { + json!({ + "namespace": relation.namespace.clone(), + "attrs": relation.attrs.clone(), + "order_index": relation.order_index, + "document_ids": relation.document_ids.clone(), + "chunk_ids": relation.chunk_ids.clone(), + "updated_at": timestamp_to_rfc3339(relation.updated_at), + }) +} + +fn chunk_metadata(hit: &NamespaceMemoryHit) -> Value { + json!({ + "kind": memory_kind_label(&hit.kind), + "namespace": hit.namespace.clone(), + "key": hit.key.clone(), + "title": hit.title.clone(), + "category": hit.category.clone(), + "source_type": hit.source_type.clone(), + "score_breakdown": { + "keyword_relevance": hit.score_breakdown.keyword_relevance, + "vector_similarity": hit.score_breakdown.vector_similarity, + "graph_relevance": hit.score_breakdown.graph_relevance, + "freshness": hit.score_breakdown.freshness, + "final_score": hit.score_breakdown.final_score, + } + }) +} + +fn build_retrieval_context(hits: &[NamespaceMemoryHit]) -> MemoryRetrievalContext { + let mut entities = BTreeSet::new(); + let mut relations = BTreeMap::new(); + let chunks = hits + .iter() + .map(|hit| { + for relation in &hit.supporting_relations { + if !relation.subject.trim().is_empty() { + entities.insert(relation.subject.clone()); + } + if !relation.object.trim().is_empty() { + entities.insert(relation.object.clone()); + } + relations + .entry(relation_identity(relation)) + .or_insert_with(|| MemoryRetrievalRelation { + subject: relation.subject.clone(), + predicate: relation.predicate.clone(), + object: relation.object.clone(), + score: None, + evidence_count: Some(relation.evidence_count), + metadata: relation_metadata(relation), + }); + } + + MemoryRetrievalChunk { + chunk_id: hit.chunk_id.clone(), + document_id: hit.document_id.clone(), + content: hit.content.clone(), + score: hit.score, + metadata: chunk_metadata(hit), + created_at: None, + updated_at: timestamp_to_rfc3339(hit.updated_at), + } + }) + .collect(); + + MemoryRetrievalContext { + entities: entities + .into_iter() + .map(|name| MemoryRetrievalEntity { + id: None, + name, + entity_type: None, + score: None, + metadata: json!({}), + }) + .collect(), + relations: relations.into_values().collect(), + chunks, + } +} + +fn format_llm_context_message(query: Option<&str>, hits: &[NamespaceMemoryHit]) -> Option { + if hits.is_empty() { + return None; + } + + let mut parts = Vec::new(); + if let Some(query) = query { + parts.push(format!("Query: {query}")); + } + + for hit in hits { + let summary = match hit.kind { + MemoryItemKind::Document => { + let title = hit.title.clone().unwrap_or_else(|| hit.key.clone()); + format!("{title}: {}", hit.content.trim()) + } + MemoryItemKind::Kv => format!("[kv:{}] {}", hit.key, hit.content.trim()), + }; + parts.push(summary); + + if !hit.supporting_relations.is_empty() { + let relations = hit + .supporting_relations + .iter() + .map(|relation| { + format!( + "{} -[{}]-> {}", + relation.subject.as_str(), + relation.predicate.as_str(), + relation.object.as_str() + ) + }) + .collect::>() + .join("; "); + parts.push(format!("Relations: {relations}")); + } + } + + Some(parts.join("\n\n")) +} + +fn filter_hits_by_document_ids( + hits: Vec, + document_ids: Option<&[String]>, +) -> Vec { + let Some(document_ids) = document_ids else { + return hits; + }; + let allowed = document_ids.iter().cloned().collect::>(); + hits.into_iter() + .filter(|hit| { + hit.document_id + .as_ref() + .map(|document_id| allowed.contains(document_id)) + .unwrap_or(false) + }) + .collect() +} + +fn maybe_retrieval_context( + include_references: bool, + context: MemoryRetrievalContext, +) -> Option { + if !include_references { + return None; + } + if context.entities.is_empty() && context.relations.is_empty() && context.chunks.is_empty() { + return None; + } + Some(context) +} + +async fn current_workspace_dir() -> Result { + Config::load_or_init() + .await + .map(|config| config.workspace_dir) + .map_err(|e| format!("load config: {e}")) +} + +async fn active_memory_client() -> Result { + if let Some(client) = lock_memory_client_state()?.clone() { + return Ok(client); + } + + let workspace_dir = current_workspace_dir().await?; + Ok(Arc::new(MemoryClient::from_workspace_dir(workspace_dir)?)) +} + +fn validate_memory_relative_path(path: &str) -> Result<(), String> { + let candidate = Path::new(path); + if candidate.as_os_str().is_empty() { + return Err("memory path must not be empty".to_string()); + } + if candidate.is_absolute() { + return Err("absolute paths are not allowed".to_string()); + } + for component in candidate.components() { + match component { + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err("path traversal is not allowed".to_string()); + } + _ => {} + } + } + Ok(()) +} + +async fn resolve_memory_root() -> Result { + let workspace_dir = current_workspace_dir().await?; + let memory_root = workspace_dir.join("memory"); + std::fs::create_dir_all(&memory_root) + .map_err(|e| format!("create memory dir {}: {e}", memory_root.display()))?; + memory_root + .canonicalize() + .map_err(|e| format!("resolve memory dir {}: {e}", memory_root.display())) +} + +async fn resolve_existing_memory_path(relative_path: &str) -> Result { + validate_memory_relative_path(relative_path)?; + let memory_root = resolve_memory_root().await?; + let workspace_root = memory_root + .parent() + .ok_or_else(|| "memory root is missing a parent workspace".to_string())?; + let full_path = workspace_root.join(relative_path); + let resolved = full_path + .canonicalize() + .map_err(|e| format!("resolve memory path {}: {e}", full_path.display()))?; + if !resolved.starts_with(&memory_root) { + return Err("memory path escapes the memory directory".to_string()); + } + Ok(resolved) +} + +async fn resolve_writable_memory_path(relative_path: &str) -> Result { + validate_memory_relative_path(relative_path)?; + let memory_root = resolve_memory_root().await?; + let workspace_root = memory_root + .parent() + .ok_or_else(|| "memory root is missing a parent workspace".to_string())?; + let full_path = workspace_root.join(relative_path); + let parent = full_path + .parent() + .ok_or_else(|| "memory path must include a file name".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("create memory path {}: {e}", parent.display()))?; + let resolved_parent = parent + .canonicalize() + .map_err(|e| format!("resolve memory parent {}: {e}", parent.display()))?; + if !resolved_parent.starts_with(&memory_root) { + return Err("memory path escapes the memory directory".to_string()); + } + let file_name = full_path + .file_name() + .ok_or_else(|| "memory path must include a file name".to_string())?; + let resolved = resolved_parent.join(file_name); + if let Ok(metadata) = std::fs::symlink_metadata(&resolved) { + if metadata.file_type().is_symlink() { + return Err(format!( + "refusing to write through symlink: {}", + resolved.display() + )); + } + } + Ok(resolved) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMemoryDocumentSummary { + document_id: String, + namespace: String, + key: String, + title: String, + source_type: String, + priority: String, + created_at: f64, + updated_at: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawDeleteDocumentResult { + deleted: bool, + namespace: String, + document_id: String, +} + +fn parse_memory_document_summaries(raw: Value) -> Result, String> { + let documents = raw + .get("documents") + .and_then(Value::as_array) + .ok_or_else(|| "memory document list missing 'documents' array".to_string())?; + documents + .iter() + .cloned() + .map(|value| { + let raw: RawMemoryDocumentSummary = serde_json::from_value(value) + .map_err(|e| format!("decode memory document: {e}"))?; + Ok(MemoryDocumentSummary { + document_id: raw.document_id, + namespace: raw.namespace, + key: raw.key, + title: raw.title, + source_type: raw.source_type, + priority: raw.priority, + created_at: raw.created_at, + updated_at: raw.updated_at, + }) + }) + .collect() +} + +async fn query_limit_for_request( + client: &MemoryClient, + request: &QueryNamespaceRequest, +) -> Result { + let requested = request.resolved_limit(); + if request.document_ids.is_none() { + return Ok(requested); + } + + let raw = client.list_documents(Some(&request.namespace)).await?; + let documents = parse_memory_document_summaries(raw)?; + let total_documents = u32::try_from(documents.len()).unwrap_or(u32::MAX); + Ok(requested.max(total_documents)) +} #[derive(Debug, Deserialize)] pub struct PutDocParams { @@ -29,12 +454,6 @@ pub struct NamespaceOnlyParams { pub namespace: String, } -#[derive(Debug, Deserialize)] -pub struct OptionalNamespaceParams { - #[serde(default)] - pub namespace: Option, -} - #[derive(Debug, Deserialize)] pub struct DeleteDocParams { pub namespace: String, @@ -110,7 +529,7 @@ fn default_category() -> String { } pub async fn namespace_list() -> Result>, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let namespaces = client.list_namespaces().await?; Ok(RpcOutcome::single_log( namespaces, @@ -119,7 +538,7 @@ pub async fn namespace_list() -> Result>, String> { } pub async fn doc_put(params: PutDocParams) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let document_id = client .put_doc(NamespaceDocumentInput { namespace: params.namespace, @@ -144,7 +563,7 @@ pub async fn doc_put(params: PutDocParams) -> Result, S pub async fn doc_list( params: Option, ) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let docs = client .list_documents(params.as_ref().map(|v| v.namespace.as_str())) .await?; @@ -152,7 +571,7 @@ pub async fn doc_list( } pub async fn doc_delete(params: DeleteDocParams) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let result = client .delete_document(¶ms.namespace, ¶ms.document_id) .await?; @@ -160,7 +579,7 @@ pub async fn doc_delete(params: DeleteDocParams) -> Result Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let result = client .query_namespace(¶ms.namespace, ¶ms.query, params.limit.unwrap_or(10)) .await?; @@ -170,7 +589,7 @@ pub async fn context_query(params: QueryNamespaceParams) -> Result Result>, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let result = client .recall_namespace(¶ms.namespace, params.limit.unwrap_or(10)) .await?; @@ -178,7 +597,7 @@ pub async fn context_recall( } pub async fn kv_set(params: KvSetParams) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; client .kv_set(params.namespace.as_deref(), ¶ms.key, ¶ms.value) .await?; @@ -188,7 +607,7 @@ pub async fn kv_set(params: KvSetParams) -> Result, String> { pub async fn kv_get( params: KvGetDeleteParams, ) -> Result>, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let value = client .kv_get(params.namespace.as_deref(), ¶ms.key) .await?; @@ -196,7 +615,7 @@ pub async fn kv_get( } pub async fn kv_delete(params: KvGetDeleteParams) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let deleted = client .kv_delete(params.namespace.as_deref(), ¶ms.key) .await?; @@ -206,13 +625,13 @@ pub async fn kv_delete(params: KvGetDeleteParams) -> Result, St pub async fn kv_list_namespace( params: NamespaceOnlyParams, ) -> Result>, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let rows = client.kv_list_namespace(¶ms.namespace).await?; Ok(RpcOutcome::single_log(rows, "memory namespace kv listed")) } pub async fn graph_upsert(params: GraphUpsertParams) -> Result, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; client .graph_upsert( params.namespace.as_deref(), @@ -228,7 +647,7 @@ pub async fn graph_upsert(params: GraphUpsertParams) -> Result, pub async fn graph_query( params: GraphQueryParams, ) -> Result>, String> { - let client = MemoryClient::new_local()?; + let client = active_memory_client().await?; let rows = client .graph_query( params.namespace.as_deref(), @@ -238,3 +657,329 @@ pub async fn graph_query( .await?; Ok(RpcOutcome::single_log(rows, "memory graph queried")) } + +pub async fn memory_init( + request: MemoryInitRequest, +) -> Result>, String> { + if request.jwt_token.trim().is_empty() { + return Err("jwt_token must not be empty".to_string()); + } + let workspace_dir = current_workspace_dir().await?; + let client = Arc::new(MemoryClient::from_workspace_dir(workspace_dir.clone())?); + *lock_memory_client_state()? = Some(client); + let memory_dir = workspace_dir.join("memory"); + Ok(envelope( + MemoryInitResponse { + initialized: true, + workspace_dir: workspace_dir.display().to_string(), + memory_dir: memory_dir.display().to_string(), + }, + None, + None, + )) +} + +pub async fn memory_list_documents( + request: ListDocumentsRequest, +) -> Result>, String> { + let client = active_memory_client().await?; + let raw = client.list_documents(request.namespace.as_deref()).await?; + let documents = parse_memory_document_summaries(raw)?; + let count = documents.len(); + Ok(envelope( + ListDocumentsResponse { + namespace: request.namespace, + documents, + count, + }, + Some(memory_counts([("num_documents", count)])), + Some(PaginationMeta { + limit: count, + offset: 0, + count, + }), + )) +} + +pub async fn memory_list_namespaces( + _request: EmptyRequest, +) -> Result>, String> { + let client = active_memory_client().await?; + let namespaces = client.list_namespaces().await?; + let count = namespaces.len(); + Ok(envelope( + ListNamespacesResponse { namespaces, count }, + Some(memory_counts([("num_namespaces", count)])), + None, + )) +} + +pub async fn memory_delete_document( + request: DeleteDocumentRequest, +) -> Result>, String> { + let client = active_memory_client().await?; + let raw = client + .delete_document(&request.namespace, &request.document_id) + .await?; + let parsed: RawDeleteDocumentResult = + serde_json::from_value(raw).map_err(|e| format!("decode delete document result: {e}"))?; + Ok(envelope( + DeleteDocumentResponse { + status: if parsed.deleted { + "completed".to_string() + } else { + "not_found".to_string() + }, + namespace: parsed.namespace, + document_id: parsed.document_id, + deleted: parsed.deleted, + }, + None, + None, + )) +} + +pub async fn memory_query_namespace( + request: QueryNamespaceRequest, +) -> Result>, String> { + let include_references = request.include_references.unwrap_or(true); + let result = async { + let client = active_memory_client().await?; + let retrieval_limit = query_limit_for_request(client.as_ref(), &request).await?; + let mut context = client + .query_namespace_context_data(&request.namespace, &request.query, retrieval_limit) + .await?; + context.hits = filter_hits_by_document_ids(context.hits, request.document_ids.as_deref()); + Ok::(context) + } + .await; + + match result { + Ok(context) => { + let retrieval_context = build_retrieval_context(&context.hits); + let counts = memory_counts([ + ("num_entities", retrieval_context.entities.len()), + ("num_relations", retrieval_context.relations.len()), + ("num_chunks", retrieval_context.chunks.len()), + ]); + let llm_context_message = + format_llm_context_message(Some(&request.query), &context.hits); + Ok(envelope( + QueryNamespaceResponse { + context: maybe_retrieval_context(include_references, retrieval_context), + llm_context_message, + }, + Some(counts), + None, + )) + } + Err(message) => Ok(error_envelope("memory.query_namespace_failed", message)), + } +} + +pub async fn memory_recall_context( + request: RecallContextRequest, +) -> Result>, String> { + let include_references = request.include_references.unwrap_or(true); + let result = async { + let client = active_memory_client().await?; + client + .recall_namespace_context_data(&request.namespace, request.resolved_limit()) + .await + } + .await; + + match result { + Ok(context) => { + let retrieval_context = build_retrieval_context(&context.hits); + let counts = memory_counts([ + ("num_entities", retrieval_context.entities.len()), + ("num_relations", retrieval_context.relations.len()), + ("num_chunks", retrieval_context.chunks.len()), + ]); + let llm_context_message = format_llm_context_message(None, &context.hits); + Ok(envelope( + RecallContextResponse { + context: maybe_retrieval_context(include_references, retrieval_context), + llm_context_message, + }, + Some(counts), + None, + )) + } + Err(message) => Ok(error_envelope("memory.recall_context_failed", message)), + } +} + +pub async fn memory_recall_memories( + request: RecallMemoriesRequest, +) -> Result>, String> { + let result = async { + let client = active_memory_client().await?; + client + .recall_namespace_memories(&request.namespace, request.resolved_limit()) + .await + } + .await; + + match result { + Ok(hits) => { + let memories = hits + .into_iter() + .map(|hit| MemoryRecallItem { + kind: memory_kind_label(&hit.kind).to_string(), + id: hit.id, + content: hit.content, + score: hit.score, + retention: None, + last_accessed_at: None, + access_count: None, + stability_days: None, + }) + .collect::>(); + let count = memories.len(); + Ok(envelope( + RecallMemoriesResponse { memories }, + Some(memory_counts([("num_memories", count)])), + None, + )) + } + Err(message) => Ok(error_envelope("memory.recall_memories_failed", message)), + } +} + +pub async fn ai_list_memory_files( + request: ListMemoryFilesRequest, +) -> Result>, String> { + validate_memory_relative_path(&request.relative_dir)?; + let directory = resolve_existing_memory_path(&request.relative_dir).await?; + if !directory.is_dir() { + return Err(format!( + "memory directory not found: {}", + directory.display() + )); + } + let mut files = Vec::new(); + for entry in std::fs::read_dir(&directory) + .map_err(|e| format!("read memory directory {}: {e}", directory.display()))? + { + let entry = entry.map_err(|e| format!("read memory directory entry: {e}"))?; + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.is_empty() { + files.push(file_name.to_string()); + } + } + files.sort(); + let count = files.len(); + Ok(envelope( + ListMemoryFilesResponse { + relative_dir: request.relative_dir, + files, + count, + }, + Some(memory_counts([("num_files", count)])), + None, + )) +} + +pub async fn ai_read_memory_file( + request: ReadMemoryFileRequest, +) -> Result>, String> { + let path = resolve_existing_memory_path(&request.relative_path).await?; + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("read memory file {}: {e}", path.display()))?; + Ok(envelope( + ReadMemoryFileResponse { + relative_path: request.relative_path, + content, + }, + None, + None, + )) +} + +pub async fn ai_write_memory_file( + request: WriteMemoryFileRequest, +) -> Result>, String> { + let path = resolve_writable_memory_path(&request.relative_path).await?; + std::fs::write(&path, request.content.as_bytes()) + .map_err(|e| format!("write memory file {}: {e}", path.display()))?; + let bytes_written = request.content.len(); + Ok(envelope( + WriteMemoryFileResponse { + relative_path: request.relative_path, + written: true, + bytes_written, + }, + None, + None, + )) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{build_retrieval_context, filter_hits_by_document_ids, format_llm_context_message}; + use crate::openhuman::memory::store::GraphRelationRecord; + use crate::openhuman::memory::{MemoryItemKind, NamespaceMemoryHit, RetrievalScoreBreakdown}; + + fn sample_hit() -> NamespaceMemoryHit { + NamespaceMemoryHit { + id: "doc-1".to_string(), + kind: MemoryItemKind::Document, + namespace: "team".to_string(), + key: "atlas-status".to_string(), + title: Some("Atlas status".to_string()), + content: "Project Atlas is owned by Alice.".to_string(), + category: "core".to_string(), + source_type: Some("doc".to_string()), + updated_at: 1_700_000_000.0, + score: 0.92, + score_breakdown: RetrievalScoreBreakdown { + keyword_relevance: 0.3, + vector_similarity: 0.4, + graph_relevance: 0.9, + freshness: 0.0, + final_score: 0.92, + }, + document_id: Some("doc-1".to_string()), + chunk_id: Some("doc-1#chunk-1".to_string()), + supporting_relations: vec![GraphRelationRecord { + namespace: Some("team".to_string()), + subject: "Alice".to_string(), + predicate: "OWNS".to_string(), + object: "Atlas".to_string(), + attrs: json!({"source": "graph"}), + updated_at: 1_700_000_000.0, + evidence_count: 2, + order_index: Some(1), + document_ids: vec!["doc-1".to_string()], + chunk_ids: vec!["doc-1#chunk-1".to_string()], + }], + } + } + + #[test] + fn build_retrieval_context_projects_hits_into_relations_and_chunks() { + let context = build_retrieval_context(&[sample_hit()]); + assert_eq!(context.entities.len(), 2); + assert_eq!(context.relations.len(), 1); + assert_eq!(context.chunks.len(), 1); + assert_eq!(context.chunks[0].document_id.as_deref(), Some("doc-1")); + assert_eq!(context.relations[0].predicate, "OWNS"); + } + + #[test] + fn helpers_filter_document_ids_and_format_context_message() { + let hit = sample_hit(); + let filtered = filter_hits_by_document_ids(vec![hit.clone()], Some(&["doc-2".to_string()])); + assert!(filtered.is_empty()); + + let message = format_llm_context_message(Some("who owns atlas"), &[hit]) + .expect("context message should exist"); + assert!(message.contains("Query: who owns atlas")); + assert!(message.contains("Alice -[OWNS]-> Atlas")); + } +} diff --git a/src/openhuman/memory/rpc_models.rs b/src/openhuman/memory/rpc_models.rs new file mode 100644 index 000000000..c58f4d8d0 --- /dev/null +++ b/src/openhuman/memory/rpc_models.rs @@ -0,0 +1,337 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaginationMeta { + pub limit: usize, + pub offset: usize, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiMeta { + pub request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cached: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub counts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pagination: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiEnvelope { + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub meta: ApiMeta, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmptyRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryInitRequest { + pub jwt_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryInitResponse { + pub initialized: bool, + pub workspace_dir: String, + pub memory_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListDocumentsRequest { + #[serde(default)] + pub namespace: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryDocumentSummary { + pub document_id: String, + pub namespace: String, + pub key: String, + pub title: String, + pub source_type: String, + pub priority: String, + pub created_at: f64, + pub updated_at: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListDocumentsResponse { + #[serde(default)] + pub namespace: Option, + pub documents: Vec, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListNamespacesResponse { + pub namespaces: Vec, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteDocumentRequest { + pub namespace: String, + pub document_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteDocumentResponse { + pub status: String, + pub namespace: String, + pub document_id: String, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QueryNamespaceRequest { + pub namespace: String, + pub query: String, + #[serde(default)] + pub include_references: Option, + #[serde(default)] + pub document_ids: Option>, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub max_chunks: Option, +} + +impl QueryNamespaceRequest { + pub fn resolved_limit(&self) -> u32 { + self.max_chunks.or(self.limit).unwrap_or(10) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryNamespaceResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_context_message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecallContextRequest { + pub namespace: String, + #[serde(default)] + pub include_references: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub max_chunks: Option, +} + +impl RecallContextRequest { + pub fn resolved_limit(&self) -> u32 { + self.max_chunks.or(self.limit).unwrap_or(10) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecallContextResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_context_message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecallMemoriesRequest { + pub namespace: String, + /// Accepted for forward compatibility and currently ignored until the core + /// recall path exposes real retention-based filtering semantics. + #[serde(default)] + pub min_retention: Option, + /// Accepted for forward compatibility and currently ignored until the core + /// recall path exposes real as-of / temporal recall semantics. + #[serde(default)] + pub as_of: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub max_chunks: Option, + #[serde(default)] + pub top_k: Option, +} + +impl RecallMemoriesRequest { + pub fn resolved_limit(&self) -> u32 { + self.top_k.or(self.max_chunks).or(self.limit).unwrap_or(10) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRetrievalEntity { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub entity_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub score: Option, + #[serde(default)] + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRetrievalRelation { + pub subject: String, + pub predicate: String, + pub object: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_count: Option, + #[serde(default)] + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRetrievalChunk { + #[serde(skip_serializing_if = "Option::is_none")] + pub chunk_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_id: Option, + pub content: String, + pub score: f64, + #[serde(default)] + pub metadata: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRetrievalContext { + pub entities: Vec, + pub relations: Vec, + pub chunks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRecallItem { + #[serde(rename = "type")] + pub kind: String, + pub id: String, + pub content: String, + pub score: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub retention: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_accessed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stability_days: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecallMemoriesResponse { + pub memories: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ListMemoryFilesRequest { + #[serde(default = "default_memory_relative_dir")] + pub relative_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListMemoryFilesResponse { + pub relative_dir: String, + pub files: Vec, + pub count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadMemoryFileRequest { + pub relative_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadMemoryFileResponse { + pub relative_path: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WriteMemoryFileRequest { + pub relative_path: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WriteMemoryFileResponse { + pub relative_path: String, + pub written: bool, + pub bytes_written: usize, +} + +fn default_memory_relative_dir() -> String { + "memory".to_string() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::RecallMemoriesRequest; + + #[test] + fn recall_memories_request_accepts_compatibility_noop_params() { + let request: RecallMemoriesRequest = serde_json::from_value(json!({ + "namespace": "team", + "top_k": 7, + "min_retention": 0.8, + "as_of": 1700000000.0 + })) + .expect("compatibility params should deserialize"); + + assert_eq!(request.namespace, "team"); + assert_eq!(request.top_k, Some(7)); + assert_eq!(request.min_retention, Some(0.8)); + assert_eq!(request.as_of, Some(1_700_000_000.0)); + } + + #[test] + fn recall_memories_request_limit_resolution_ignores_compatibility_noop_params() { + let request: RecallMemoriesRequest = serde_json::from_value(json!({ + "namespace": "team", + "limit": 3, + "min_retention": 0.5, + "as_of": 1700000000.0 + })) + .expect("request should deserialize"); + + assert_eq!(request.resolved_limit(), 3); + } +} diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index 556ecf199..04e897200 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -1,8 +1,11 @@ use serde_json::json; +use std::path::PathBuf; use std::sync::Arc; use crate::openhuman::memory::embeddings::{self, EmbeddingProvider}; -use crate::openhuman::memory::store::types::NamespaceDocumentInput; +use crate::openhuman::memory::store::types::{ + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, +}; use crate::openhuman::memory::store::unified::UnifiedMemory; pub type MemoryClientRef = Arc; @@ -24,9 +27,13 @@ impl MemoryClient { .ok_or_else(|| "Failed to resolve home directory".to_string())? .join(".openhuman") .join("workspace"); + Self::from_workspace_dir(workspace_dir) + } + + pub fn from_workspace_dir(workspace_dir: PathBuf) -> Result { std::fs::create_dir_all(&workspace_dir) .map_err(|e| format!("Create workspace dir {}: {e}", workspace_dir.display()))?; - let embedder: Arc = Arc::new(embeddings::NoopEmbedding); + let embedder: Arc = embeddings::default_local_embedding_provider(); let memory = UnifiedMemory::new(&workspace_dir, embedder, None).map_err(|e| format!("{e}"))?; Ok(Self { @@ -121,6 +128,17 @@ impl MemoryClient { .await } + pub async fn query_namespace_context_data( + &self, + namespace: &str, + query: &str, + max_chunks: u32, + ) -> Result { + self.inner + .query_namespace_context_data(namespace, query, max_chunks) + .await + } + pub async fn recall_namespace( &self, namespace: &str, @@ -131,6 +149,24 @@ impl MemoryClient { .await } + pub async fn recall_namespace_context_data( + &self, + namespace: &str, + max_chunks: u32, + ) -> Result { + self.inner + .recall_namespace_context_data(namespace, max_chunks) + .await + } + + pub async fn recall_namespace_memories( + &self, + namespace: &str, + limit: u32, + ) -> Result, String> { + self.inner.recall_namespace_memories(namespace, limit).await + } + pub async fn kv_set( &self, namespace: Option<&str>, diff --git a/src/openhuman/memory/store/memory_trait.rs b/src/openhuman/memory/store/memory_trait.rs index 810d44f16..87377440c 100644 --- a/src/openhuman/memory/store/memory_trait.rs +++ b/src/openhuman/memory/store/memory_trait.rs @@ -70,7 +70,7 @@ impl Memory for UnifiedMemory { category: memory_category_from_stored(&r.category), timestamp: Utc::now().to_rfc3339(), session_id: None, - score: Some(r.score * 100.0), + score: Some(r.score), }) .collect(); Ok(out) diff --git a/src/openhuman/memory/store/mod.rs b/src/openhuman/memory/store/mod.rs index 490d37017..2ef23e868 100644 --- a/src/openhuman/memory/store/mod.rs +++ b/src/openhuman/memory/store/mod.rs @@ -12,5 +12,9 @@ pub use factories::{ create_memory, create_memory_for_migration, create_memory_with_storage, create_memory_with_storage_and_routes, effective_memory_backend_name, }; -pub use types::{NamespaceDocumentInput, NamespaceQueryResult}; +pub use types::{ + GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput, + NamespaceMemoryHit, NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, + StoredMemoryDocument, +}; pub use unified::UnifiedMemory; diff --git a/src/openhuman/memory/store/types.rs b/src/openhuman/memory/store/types.rs index 22299f47e..0facc7d81 100644 --- a/src/openhuman/memory/store/types.rs +++ b/src/openhuman/memory/store/types.rs @@ -31,3 +31,88 @@ pub struct NamespaceQueryResult { /// Stored category string (e.g. `core`, `daily`, or custom label). pub category: String, } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryItemKind { + Document, + Kv, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredMemoryDocument { + pub document_id: String, + pub namespace: String, + pub key: String, + pub title: String, + pub content: String, + pub source_type: String, + pub priority: String, + pub tags: Vec, + pub metadata: serde_json::Value, + pub category: String, + pub session_id: Option, + pub created_at: f64, + pub updated_at: f64, + pub markdown_rel_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryKvRecord { + pub namespace: Option, + pub key: String, + pub value: serde_json::Value, + pub updated_at: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphRelationRecord { + pub namespace: Option, + pub subject: String, + pub predicate: String, + pub object: String, + pub attrs: serde_json::Value, + pub updated_at: f64, + pub evidence_count: u32, + pub order_index: Option, + pub document_ids: Vec, + pub chunk_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RetrievalScoreBreakdown { + pub keyword_relevance: f64, + pub vector_similarity: f64, + pub graph_relevance: f64, + pub freshness: f64, + pub final_score: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceMemoryHit { + pub id: String, + pub kind: MemoryItemKind, + pub namespace: String, + pub key: String, + pub title: Option, + pub content: String, + pub category: String, + pub source_type: Option, + pub updated_at: f64, + pub score: f64, + pub score_breakdown: RetrievalScoreBreakdown, + #[serde(default)] + pub document_id: Option, + #[serde(default)] + pub chunk_id: Option, + #[serde(default)] + pub supporting_relations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceRetrievalContext { + pub namespace: String, + pub query: Option, + pub context_text: String, + pub hits: Vec, +} diff --git a/src/openhuman/memory/store/unified/documents.rs b/src/openhuman/memory/store/unified/documents.rs index a46dc8531..4211c7402 100644 --- a/src/openhuman/memory/store/unified/documents.rs +++ b/src/openhuman/memory/store/unified/documents.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use std::collections::BTreeSet; use uuid::Uuid; -use crate::openhuman::memory::store::types::NamespaceDocumentInput; +use crate::openhuman::memory::store::types::{NamespaceDocumentInput, StoredMemoryDocument}; use super::UnifiedMemory; @@ -14,11 +14,32 @@ impl UnifiedMemory { if key.is_empty() { return Err("document key cannot be empty".to_string()); } + let existing_document_id = { + let conn = self.conn.lock(); + conn.query_row( + "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", + params![namespace, key], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("lookup existing document_id: {e}"))? + }; let document_id = input .document_id + .or(existing_document_id) .unwrap_or_else(|| Uuid::new_v4().to_string()); let now = Self::now_ts(); - let created_at = now; + let created_at = { + let conn = self.conn.lock(); + conn.query_row( + "SELECT created_at FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", + params![namespace, key], + |row| row.get::<_, f64>(0), + ) + .optional() + .map_err(|e| format!("lookup existing created_at: {e}"))? + .unwrap_or(now) + }; let updated_at = now; let markdown_rel = self .write_markdown_doc( @@ -84,7 +105,7 @@ impl UnifiedMemory { tx.commit().map_err(|e| format!("commit tx: {e}"))?; } - let chunks = Self::split_chunks(&input.content, 900); + let chunks = Self::chunk_document_content(&input.content, 225); for (idx, chunk) in chunks.iter().enumerate() { let embedding = self .embedder @@ -115,6 +136,64 @@ impl UnifiedMemory { Ok(document_id) } + pub(crate) async fn load_documents_for_scope( + &self, + namespace: &str, + ) -> Result, String> { + let conn = self.conn.lock(); + let ns = Self::sanitize_namespace(namespace); + let mut stmt = conn + .prepare( + "SELECT + document_id, + namespace, + key, + title, + content, + source_type, + priority, + tags_json, + metadata_json, + category, + session_id, + created_at, + updated_at, + markdown_rel_path + FROM memory_docs + WHERE namespace = ?1 + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("prepare load_documents_for_scope: {e}"))?; + let mut rows = stmt + .query(params![ns]) + .map_err(|e| format!("query load_documents_for_scope: {e}"))?; + let mut docs = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("row load_documents_for_scope: {e}"))? + { + let tags_json: String = row.get(7).map_err(|e| e.to_string())?; + let metadata_json: String = row.get(8).map_err(|e| e.to_string())?; + docs.push(StoredMemoryDocument { + document_id: row.get(0).map_err(|e| e.to_string())?, + namespace: row.get(1).map_err(|e| e.to_string())?, + key: row.get(2).map_err(|e| e.to_string())?, + title: row.get(3).map_err(|e| e.to_string())?, + content: row.get(4).map_err(|e| e.to_string())?, + source_type: row.get(5).map_err(|e| e.to_string())?, + priority: row.get(6).map_err(|e| e.to_string())?, + tags: serde_json::from_str(&tags_json).unwrap_or_default(), + metadata: serde_json::from_str(&metadata_json).unwrap_or_else(|_| json!({})), + category: row.get(9).map_err(|e| e.to_string())?, + session_id: row.get(10).map_err(|e| e.to_string())?, + created_at: row.get(11).map_err(|e| e.to_string())?, + updated_at: row.get(12).map_err(|e| e.to_string())?, + markdown_rel_path: row.get(13).map_err(|e| e.to_string())?, + }); + } + Ok(docs) + } + pub async fn list_documents(&self, namespace: Option<&str>) -> Result { let conn = self.conn.lock(); let mut docs = Vec::new(); @@ -199,27 +278,36 @@ impl UnifiedMemory { document_id: &str, ) -> Result { let ns = Self::sanitize_namespace(namespace); - let conn = self.conn.lock(); - let rel_path: Option = conn - .query_row( + let rel_path: Option = { + let conn = self.conn.lock(); + conn.query_row( "SELECT markdown_rel_path FROM memory_docs WHERE namespace = ?1 AND document_id = ?2", params![ns, document_id], |row| row.get(0), ) .optional() - .map_err(|e| format!("query delete_document path: {e}"))?; - let deleted = conn - .execute( - "DELETE FROM memory_docs WHERE namespace = ?1 AND document_id = ?2", + .map_err(|e| format!("query delete_document path: {e}"))? + }; + + self.graph_remove_document_namespace(&ns, document_id) + .await?; + + let deleted = { + let conn = self.conn.lock(); + let deleted = conn + .execute( + "DELETE FROM memory_docs WHERE namespace = ?1 AND document_id = ?2", + params![ns, document_id], + ) + .map_err(|e| format!("delete memory_doc: {e}"))? + > 0; + conn.execute( + "DELETE FROM vector_chunks WHERE namespace = ?1 AND document_id = ?2", params![ns, document_id], ) - .map_err(|e| format!("delete memory_doc: {e}"))? - > 0; - conn.execute( - "DELETE FROM vector_chunks WHERE namespace = ?1 AND document_id = ?2", - params![ns, document_id], - ) - .map_err(|e| format!("delete vector_chunks: {e}"))?; + .map_err(|e| format!("delete vector_chunks: {e}"))?; + deleted + }; if let Some(rel) = rel_path { let abs = self.workspace_dir.join(rel); diff --git a/src/openhuman/memory/store/unified/graph.rs b/src/openhuman/memory/store/unified/graph.rs index e0e72d00d..2e1ef0bfc 100644 --- a/src/openhuman/memory/store/unified/graph.rs +++ b/src/openhuman/memory/store/unified/graph.rs @@ -1,9 +1,99 @@ -use rusqlite::params; -use serde_json::json; +use rusqlite::{params, OptionalExtension}; +use serde_json::{json, Map, Value}; + +use crate::openhuman::memory::store::types::GraphRelationRecord; use super::UnifiedMemory; impl UnifiedMemory { + pub(crate) async fn graph_remove_document_namespace( + &self, + namespace: &str, + document_id: &str, + ) -> Result<(), String> { + let relations = self + .graph_relations_namespace(namespace, None, None) + .await?; + if relations.is_empty() { + return Ok(()); + } + + let doc_prefix = format!("{document_id}:"); + let updated_at = Self::now_ts(); + let conn = self.conn.lock(); + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("graph_remove_document_namespace begin tx: {e}"))?; + + for relation in relations { + let touches_document = relation.document_ids.iter().any(|id| id == document_id) + || relation + .chunk_ids + .iter() + .any(|chunk_id| chunk_id.starts_with(&doc_prefix)); + if !touches_document { + continue; + } + + let mut attrs = relation.attrs.as_object().cloned().unwrap_or_default(); + let document_ids = relation + .document_ids + .iter() + .filter(|id| id.as_str() != document_id) + .cloned() + .collect::>(); + let chunk_ids = relation + .chunk_ids + .iter() + .filter(|chunk_id| !chunk_id.starts_with(&doc_prefix)) + .cloned() + .collect::>(); + + if document_ids.is_empty() && chunk_ids.is_empty() { + tx.execute( + "DELETE FROM graph_namespace + WHERE namespace = ?1 AND subject = ?2 AND predicate = ?3 AND object = ?4", + params![ + Self::sanitize_namespace(namespace), + relation.subject, + relation.predicate, + relation.object + ], + ) + .map_err(|e| format!("graph_remove_document_namespace delete: {e}"))?; + continue; + } + + attrs.insert("document_ids".to_string(), json!(document_ids)); + if chunk_ids.is_empty() { + attrs.remove("chunk_ids"); + } else { + attrs.insert("chunk_ids".to_string(), json!(chunk_ids.clone())); + } + attrs.insert("evidence_count".to_string(), json!(chunk_ids.len().max(1))); + attrs.insert("updated_at".to_string(), json!(updated_at)); + + tx.execute( + "UPDATE graph_namespace + SET attrs_json = ?1, updated_at = ?2 + WHERE namespace = ?3 AND subject = ?4 AND predicate = ?5 AND object = ?6", + params![ + Value::Object(attrs).to_string(), + updated_at, + Self::sanitize_namespace(namespace), + relation.subject, + relation.predicate, + relation.object + ], + ) + .map_err(|e| format!("graph_remove_document_namespace update: {e}"))?; + } + + tx.commit() + .map_err(|e| format!("graph_remove_document_namespace commit: {e}"))?; + Ok(()) + } + pub async fn graph_upsert_global( &self, subject: &str, @@ -11,15 +101,8 @@ impl UnifiedMemory { object: &str, attrs: &serde_json::Value, ) -> Result<(), String> { - let conn = self.conn.lock(); - conn.execute( - "INSERT INTO graph_global (subject, predicate, object, attrs_json, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(subject, predicate, object) DO UPDATE SET attrs_json = excluded.attrs_json, updated_at = excluded.updated_at", - params![subject, predicate, object, attrs.to_string(), Self::now_ts()], - ) - .map_err(|e| format!("graph_upsert_global: {e}"))?; - Ok(()) + self.graph_upsert_internal(None, subject, predicate, object, attrs) + .await } pub async fn graph_upsert_namespace( @@ -30,22 +113,8 @@ impl UnifiedMemory { object: &str, attrs: &serde_json::Value, ) -> Result<(), String> { - let conn = self.conn.lock(); - conn.execute( - "INSERT INTO graph_namespace (namespace, subject, predicate, object, attrs_json, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(namespace, subject, predicate, object) DO UPDATE SET attrs_json = excluded.attrs_json, updated_at = excluded.updated_at", - params![ - Self::sanitize_namespace(namespace), - subject, - predicate, - object, - attrs.to_string(), - Self::now_ts() - ], - ) - .map_err(|e| format!("graph_upsert_namespace: {e}"))?; - Ok(()) + self.graph_upsert_internal(Some(namespace), subject, predicate, object, attrs) + .await } pub async fn graph_query_global( @@ -53,7 +122,94 @@ impl UnifiedMemory { subject: Option<&str>, predicate: Option<&str>, ) -> Result, String> { + let rows = self.graph_relations_global(subject, predicate).await?; + Ok(rows + .into_iter() + .map(Self::graph_relation_to_json) + .collect::>()) + } + + pub async fn graph_query_namespace( + &self, + namespace: &str, + subject: Option<&str>, + predicate: Option<&str>, + ) -> Result, String> { + let rows = self + .graph_relations_namespace(namespace, subject, predicate) + .await?; + Ok(rows + .into_iter() + .map(Self::graph_relation_to_json) + .collect::>()) + } + + pub(crate) async fn graph_relations_for_scope( + &self, + namespace: &str, + ) -> Result, String> { + let mut rows = self + .graph_relations_namespace(namespace, None, None) + .await?; + rows.extend(self.graph_relations_global(None, None).await?); + rows.sort_by(|a, b| { + b.updated_at + .partial_cmp(&a.updated_at) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(rows) + } + + pub(crate) async fn graph_relations_namespace( + &self, + namespace: &str, + subject: Option<&str>, + predicate: Option<&str>, + ) -> Result, String> { let conn = self.conn.lock(); + let ns = Self::sanitize_namespace(namespace); + let subject = subject.map(Self::normalize_graph_entity); + let predicate = predicate.map(Self::normalize_graph_predicate); + let mut stmt = conn + .prepare( + "SELECT subject, predicate, object, attrs_json, updated_at + FROM graph_namespace + WHERE namespace = ?1 + AND (?2 IS NULL OR subject = ?2) + AND (?3 IS NULL OR predicate = ?3) + ORDER BY updated_at DESC + LIMIT 300", + ) + .map_err(|e| format!("graph_relations_namespace prepare: {e}"))?; + let mut rows = stmt + .query(params![ns, subject, predicate]) + .map_err(|e| format!("graph_relations_namespace query: {e}"))?; + let mut out = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("graph_relations_namespace row: {e}"))? + { + let attrs_raw: String = row.get(3).map_err(|e| e.to_string())?; + out.push(Self::graph_relation_from_parts( + Some(Self::sanitize_namespace(namespace)), + row.get(0).map_err(|e| e.to_string())?, + row.get(1).map_err(|e| e.to_string())?, + row.get(2).map_err(|e| e.to_string())?, + &attrs_raw, + row.get(4).map_err(|e| e.to_string())?, + )); + } + Ok(out) + } + + pub(crate) async fn graph_relations_global( + &self, + subject: Option<&str>, + predicate: Option<&str>, + ) -> Result, String> { + let conn = self.conn.lock(); + let subject = subject.map(Self::normalize_graph_entity); + let predicate = predicate.map(Self::normalize_graph_predicate); let mut stmt = conn .prepare( "SELECT subject, predicate, object, attrs_json, updated_at @@ -63,63 +219,216 @@ impl UnifiedMemory { ORDER BY updated_at DESC LIMIT 300", ) - .map_err(|e| format!("graph_query_global prepare: {e}"))?; + .map_err(|e| format!("graph_relations_global prepare: {e}"))?; let mut rows = stmt .query(params![subject, predicate]) - .map_err(|e| format!("graph_query_global query: {e}"))?; + .map_err(|e| format!("graph_relations_global query: {e}"))?; let mut out = Vec::new(); while let Some(row) = rows .next() - .map_err(|e| format!("graph_query_global row: {e}"))? + .map_err(|e| format!("graph_relations_global row: {e}"))? { let attrs_raw: String = row.get(3).map_err(|e| e.to_string())?; - out.push(json!({ - "subject": row.get::<_, String>(0).map_err(|e| e.to_string())?, - "predicate": row.get::<_, String>(1).map_err(|e| e.to_string())?, - "object": row.get::<_, String>(2).map_err(|e| e.to_string())?, - "attrs": serde_json::from_str::(&attrs_raw).unwrap_or_else(|_| json!({})), - "updatedAt": row.get::<_, f64>(4).map_err(|e| e.to_string())?, - })); + out.push(Self::graph_relation_from_parts( + None, + row.get(0).map_err(|e| e.to_string())?, + row.get(1).map_err(|e| e.to_string())?, + row.get(2).map_err(|e| e.to_string())?, + &attrs_raw, + row.get(4).map_err(|e| e.to_string())?, + )); } Ok(out) } - pub async fn graph_query_namespace( + async fn graph_upsert_internal( &self, - namespace: &str, - subject: Option<&str>, - predicate: Option<&str>, - ) -> Result, String> { + namespace: Option<&str>, + subject: &str, + predicate: &str, + object: &str, + attrs: &serde_json::Value, + ) -> Result<(), String> { + let subject = Self::normalize_graph_entity(subject); + let predicate = Self::normalize_graph_predicate(predicate); + let object = Self::normalize_graph_entity(object); + let updated_at = Self::now_ts(); let conn = self.conn.lock(); - let ns = Self::sanitize_namespace(namespace); - let mut stmt = conn - .prepare( - "SELECT subject, predicate, object, attrs_json, updated_at - FROM graph_namespace - WHERE namespace = ?1 - AND (?2 IS NULL OR subject = ?2) - AND (?3 IS NULL OR predicate = ?3) - ORDER BY updated_at DESC - LIMIT 300", - ) - .map_err(|e| format!("graph_query_namespace prepare: {e}"))?; - let mut rows = stmt - .query(params![ns, subject, predicate]) - .map_err(|e| format!("graph_query_namespace query: {e}"))?; - let mut out = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| format!("graph_query_namespace row: {e}"))? - { - let attrs_raw: String = row.get(3).map_err(|e| e.to_string())?; - out.push(json!({ - "subject": row.get::<_, String>(0).map_err(|e| e.to_string())?, - "predicate": row.get::<_, String>(1).map_err(|e| e.to_string())?, - "object": row.get::<_, String>(2).map_err(|e| e.to_string())?, - "attrs": serde_json::from_str::(&attrs_raw).unwrap_or_else(|_| json!({})), - "updatedAt": row.get::<_, f64>(4).map_err(|e| e.to_string())?, - })); + + let existing_attrs: Option = match namespace { + Some(ns) => conn + .query_row( + "SELECT attrs_json + FROM graph_namespace + WHERE namespace = ?1 AND subject = ?2 AND predicate = ?3 AND object = ?4", + params![Self::sanitize_namespace(ns), subject, predicate, object], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("graph_upsert_namespace lookup: {e}"))?, + None => conn + .query_row( + "SELECT attrs_json + FROM graph_global + WHERE subject = ?1 AND predicate = ?2 AND object = ?3", + params![subject, predicate, object], + |row| row.get(0), + ) + .optional() + .map_err(|e| format!("graph_upsert_global lookup: {e}"))?, + }; + + let merged_attrs = Self::merge_graph_attrs(existing_attrs.as_deref(), attrs, updated_at); + let merged_attrs_json = merged_attrs.to_string(); + + match namespace { + Some(ns) => { + conn.execute( + "INSERT INTO graph_namespace (namespace, subject, predicate, object, attrs_json, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(namespace, subject, predicate, object) + DO UPDATE SET attrs_json = excluded.attrs_json, updated_at = excluded.updated_at", + params![ + Self::sanitize_namespace(ns), + subject, + predicate, + object, + merged_attrs_json, + updated_at + ], + ) + .map_err(|e| format!("graph_upsert_namespace: {e}"))?; + } + None => { + conn.execute( + "INSERT INTO graph_global (subject, predicate, object, attrs_json, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(subject, predicate, object) + DO UPDATE SET attrs_json = excluded.attrs_json, updated_at = excluded.updated_at", + params![subject, predicate, object, merged_attrs_json, updated_at], + ) + .map_err(|e| format!("graph_upsert_global: {e}"))?; + } } - Ok(out) + + Ok(()) + } + + fn merge_graph_attrs( + existing_attrs_raw: Option<&str>, + incoming_attrs: &Value, + updated_at: f64, + ) -> Value { + let existing = existing_attrs_raw + .and_then(|raw| serde_json::from_str::(raw).ok()) + .unwrap_or_else(|| json!({})); + let existing_evidence = Self::json_i64(&existing, "evidence_count") + .unwrap_or(0) + .max(0) as u64; + let existing_document_ids = + Self::json_string_array(&existing, "document_ids", "document_id"); + let existing_chunk_ids = Self::json_string_array(&existing, "chunk_ids", "chunk_id"); + + let mut merged = match existing { + Value::Object(map) => map, + _ => Map::new(), + }; + let incoming_map = incoming_attrs.as_object().cloned().unwrap_or_default(); + let existing_order_index = Self::json_i64(&Value::Object(merged.clone()), "order_index"); + let incoming_order_index = Self::json_i64(incoming_attrs, "order_index"); + let merged_order_index = match (existing_order_index, incoming_order_index) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + }; + + for (key, value) in incoming_map { + merged.insert(key, value); + } + + let incoming_evidence = Self::json_i64(incoming_attrs, "evidence_count") + .unwrap_or(1) + .max(0) as u64; + let evidence_count = existing_evidence.saturating_add(incoming_evidence).max(1); + + merged.insert("evidence_count".to_string(), json!(evidence_count)); + merged.insert("updated_at".to_string(), json!(updated_at)); + + let mut document_ids = existing_document_ids; + document_ids.extend(Self::json_string_array( + incoming_attrs, + "document_ids", + "document_id", + )); + document_ids.sort(); + document_ids.dedup(); + if !document_ids.is_empty() { + merged.insert("document_ids".to_string(), json!(document_ids)); + } + + let mut chunk_ids = existing_chunk_ids; + chunk_ids.extend(Self::json_string_array( + incoming_attrs, + "chunk_ids", + "chunk_id", + )); + chunk_ids.sort(); + chunk_ids.dedup(); + if !chunk_ids.is_empty() { + merged.insert("chunk_ids".to_string(), json!(chunk_ids)); + } + + if !merged.contains_key("created_at") { + merged.insert("created_at".to_string(), json!(updated_at)); + } + if let Some(order_index) = merged_order_index { + merged.insert("order_index".to_string(), json!(order_index)); + } + + Value::Object(merged) + } + + fn graph_relation_from_parts( + namespace: Option, + subject: String, + predicate: String, + object: String, + attrs_raw: &str, + updated_at: f64, + ) -> GraphRelationRecord { + let attrs = serde_json::from_str::(attrs_raw).unwrap_or_else(|_| json!({})); + let evidence_count = Self::json_i64(&attrs, "evidence_count").unwrap_or(1).max(1) as u32; + let order_index = Self::json_i64(&attrs, "order_index"); + let document_ids = Self::json_string_array(&attrs, "document_ids", "document_id"); + let chunk_ids = Self::json_string_array(&attrs, "chunk_ids", "chunk_id"); + + GraphRelationRecord { + namespace, + subject, + predicate, + object, + attrs, + updated_at, + evidence_count, + order_index, + document_ids, + chunk_ids, + } + } + + fn graph_relation_to_json(record: GraphRelationRecord) -> serde_json::Value { + json!({ + "namespace": record.namespace, + "subject": record.subject, + "predicate": record.predicate, + "object": record.object, + "attrs": record.attrs, + "updatedAt": record.updated_at, + "evidenceCount": record.evidence_count, + "orderIndex": record.order_index, + "documentIds": record.document_ids, + "chunkIds": record.chunk_ids, + }) } } diff --git a/src/openhuman/memory/store/unified/helpers.rs b/src/openhuman/memory/store/unified/helpers.rs index 6bb71d60f..4749b366e 100644 --- a/src/openhuman/memory/store/unified/helpers.rs +++ b/src/openhuman/memory/store/unified/helpers.rs @@ -1,3 +1,5 @@ +use crate::openhuman::memory::chunker::chunk_markdown; + use super::UnifiedMemory; impl UnifiedMemory { @@ -74,29 +76,114 @@ impl UnifiedMemory { (dot / denom).clamp(0.0, 1.0) } - pub(crate) fn split_chunks(content: &str, max_len: usize) -> Vec { - let mut out = Vec::new(); - let mut current = String::new(); - for para in content.split("\n\n") { - let p = para.trim(); - if p.is_empty() { - continue; - } - if current.is_empty() { - current.push_str(p); - continue; - } - if current.len() + 2 + p.len() <= max_len { - current.push_str("\n\n"); - current.push_str(p); - } else { - out.push(std::mem::take(&mut current)); - current.push_str(p); + pub(crate) fn chunk_document_content(content: &str, max_tokens: usize) -> Vec { + let mut chunks: Vec = chunk_markdown(content, max_tokens.max(1)) + .into_iter() + .map(|chunk| chunk.content.trim().to_string()) + .filter(|chunk: &String| !chunk.is_empty()) + .collect(); + if chunks.is_empty() && !content.trim().is_empty() { + chunks.push(content.trim().to_string()); + } + chunks + } + + pub(crate) fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") + } + + pub(crate) fn normalize_search_text(text: &str) -> String { + let collapsed = Self::collapse_whitespace(text); + let mut normalized = String::with_capacity(collapsed.len()); + for ch in collapsed.chars() { + if ch.is_alphanumeric() { + normalized.extend(ch.to_lowercase()); + } else if ch.is_whitespace() || matches!(ch, '_' | '-' | '/' | '.') { + normalized.push(' '); } } - if !current.trim().is_empty() { - out.push(current); + normalized.split_whitespace().collect::>().join(" ") + } + + pub(crate) fn tokenize_search_terms(text: &str) -> Vec { + Self::normalize_search_text(text) + .split_whitespace() + .map(ToOwned::to_owned) + .collect() + } + + pub(crate) fn normalize_graph_entity(text: &str) -> String { + Self::collapse_whitespace(text.trim()).to_uppercase() + } + + pub(crate) fn normalize_graph_predicate(text: &str) -> String { + let mut out = String::new(); + let mut last_was_sep = false; + for ch in Self::collapse_whitespace(text.trim()).chars() { + if ch.is_alphanumeric() { + out.extend(ch.to_uppercase()); + last_was_sep = false; + } else if !last_was_sep { + out.push('_'); + last_was_sep = true; + } } - out + out.trim_matches('_').to_string() + } + + pub(crate) fn json_string_array( + value: &serde_json::Value, + primary_key: &str, + singular_key: &str, + ) -> Vec { + let mut items = Vec::new(); + if let Some(array) = value.get(primary_key).and_then(serde_json::Value::as_array) { + for item in array { + if let Some(text) = item.as_str() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + items.push(trimmed.to_string()); + } + } + } + } + if let Some(text) = value.get(singular_key).and_then(serde_json::Value::as_str) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + items.push(trimmed.to_string()); + } + } + items.sort(); + items.dedup(); + items + } + + pub(crate) fn merge_unique_string_arrays( + current: &serde_json::Value, + incoming: &serde_json::Value, + primary_key: &str, + singular_key: &str, + ) -> Vec { + let mut merged = Self::json_string_array(current, primary_key, singular_key); + merged.extend(Self::json_string_array(incoming, primary_key, singular_key)); + merged.sort(); + merged.dedup(); + merged + } + + pub(crate) fn json_i64(value: &serde_json::Value, key: &str) -> Option { + value.get(key).and_then(|raw| { + raw.as_i64().or_else(|| { + raw.as_u64() + .and_then(|v| i64::try_from(v).ok()) + .or_else(|| raw.as_f64().map(|v| v as i64)) + }) + }) + } + + pub(crate) fn recency_score(updated_at: f64, now: f64) -> f64 { + let age_secs = (now - updated_at).max(0.0); + let age_hours = age_secs / 3600.0; + (1.0 / (1.0 + age_hours / 24.0)).clamp(0.0, 1.0) } } diff --git a/src/openhuman/memory/store/unified/init.rs b/src/openhuman/memory/store/unified/init.rs index 612ed28ab..3921d3431 100644 --- a/src/openhuman/memory/store/unified/init.rs +++ b/src/openhuman/memory/store/unified/init.rs @@ -23,6 +23,13 @@ impl UnifiedMemory { let db_path = memory_dir.join("memory.db"); let conn = Connection::open(&db_path)?; + // Active storage layout for the core memory domain: + // - memory_docs: namespace-scoped source documents and markdown metadata. + // - vector_chunks: chunked document text plus optional local embedding bytes. + // - graph_namespace: namespace graph edges used for relation-first retrieval. + // - graph_global: cross-namespace graph edges used as fallback/shared memory. + // - kv_namespace: namespace-scoped durable preferences, decisions, and state. + // - kv_global: global durable key-value memories outside a namespace scope. conn.execute_batch( "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; @@ -69,6 +76,7 @@ impl UnifiedMemory { updated_at REAL NOT NULL, PRIMARY KEY(subject, predicate, object) ); + CREATE INDEX IF NOT EXISTS idx_graph_global_subject ON graph_global(subject, predicate); CREATE TABLE IF NOT EXISTS graph_namespace ( namespace TEXT NOT NULL, @@ -80,6 +88,7 @@ impl UnifiedMemory { PRIMARY KEY(namespace, subject, predicate, object) ); CREATE INDEX IF NOT EXISTS idx_graph_namespace_ns ON graph_namespace(namespace); + CREATE INDEX IF NOT EXISTS idx_graph_namespace_subject ON graph_namespace(namespace, subject, predicate); CREATE TABLE IF NOT EXISTS vector_chunks ( namespace TEXT NOT NULL, diff --git a/src/openhuman/memory/store/unified/kv.rs b/src/openhuman/memory/store/unified/kv.rs index 4004e3347..72f365475 100644 --- a/src/openhuman/memory/store/unified/kv.rs +++ b/src/openhuman/memory/store/unified/kv.rs @@ -1,6 +1,8 @@ use rusqlite::{params, OptionalExtension}; use serde_json::json; +use crate::openhuman::memory::store::types::MemoryKvRecord; + use super::UnifiedMemory; impl UnifiedMemory { @@ -110,4 +112,76 @@ impl UnifiedMemory { } Ok(out) } + + pub(crate) async fn kv_records_for_scope( + &self, + namespace: &str, + ) -> Result, String> { + let mut records = self.kv_records_namespace(namespace).await?; + records.extend(self.kv_records_global().await?); + records.sort_by(|a, b| { + b.updated_at + .partial_cmp(&a.updated_at) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(records) + } + + pub(crate) async fn kv_records_namespace( + &self, + namespace: &str, + ) -> Result, String> { + let conn = self.conn.lock(); + let mut stmt = conn + .prepare( + "SELECT key, value_json, updated_at FROM kv_namespace + WHERE namespace = ?1 + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("prepare kv_records_namespace: {e}"))?; + let mut rows = stmt + .query(params![Self::sanitize_namespace(namespace)]) + .map_err(|e| format!("query kv_records_namespace: {e}"))?; + let mut out = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("row kv_records_namespace: {e}"))? + { + let value_raw: String = row.get(1).map_err(|e| e.to_string())?; + out.push(MemoryKvRecord { + namespace: Some(Self::sanitize_namespace(namespace)), + key: row.get(0).map_err(|e| e.to_string())?, + value: serde_json::from_str(&value_raw).unwrap_or(serde_json::Value::Null), + updated_at: row.get(2).map_err(|e| e.to_string())?, + }); + } + Ok(out) + } + + pub(crate) async fn kv_records_global(&self) -> Result, String> { + let conn = self.conn.lock(); + let mut stmt = conn + .prepare( + "SELECT key, value_json, updated_at FROM kv_global + ORDER BY updated_at DESC", + ) + .map_err(|e| format!("prepare kv_records_global: {e}"))?; + let mut rows = stmt + .query([]) + .map_err(|e| format!("query kv_records_global: {e}"))?; + let mut out = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("row kv_records_global: {e}"))? + { + let value_raw: String = row.get(1).map_err(|e| e.to_string())?; + out.push(MemoryKvRecord { + namespace: None, + key: row.get(0).map_err(|e| e.to_string())?, + value: serde_json::from_str(&value_raw).unwrap_or(serde_json::Value::Null), + updated_at: row.get(2).map_err(|e| e.to_string())?, + }); + } + Ok(out) + } } diff --git a/src/openhuman/memory/store/unified/query.rs b/src/openhuman/memory/store/unified/query.rs index c31c5eeac..ce1e8d155 100644 --- a/src/openhuman/memory/store/unified/query.rs +++ b/src/openhuman/memory/store/unified/query.rs @@ -1,86 +1,203 @@ use rusqlite::params; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use crate::openhuman::memory::store::types::NamespaceQueryResult; +use crate::openhuman::memory::store::types::{ + GraphRelationRecord, MemoryItemKind, NamespaceMemoryHit, NamespaceQueryResult, + NamespaceRetrievalContext, RetrievalScoreBreakdown, +}; use super::UnifiedMemory; +const GRAPH_WEIGHT: f64 = 0.55; +const VECTOR_WEIGHT: f64 = 0.30; +const KEYWORD_WEIGHT: f64 = 0.15; + +const RECALL_PRIORITY_WEIGHT: f64 = 0.45; +const RECALL_GRAPH_WEIGHT: f64 = 0.30; +const RECALL_FRESHNESS_WEIGHT: f64 = 0.25; + +#[derive(Debug, Clone)] +struct StoredChunk { + document_id: String, + chunk_id: String, + text: String, + embedding: Option>, + updated_at: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TemporalOperator { + Latest, + Earliest, + Before, + After, + All, +} + +#[derive(Debug, Clone)] +struct RetrievalPlan { + query_terms: Vec, + seed_entities: Vec, + relation_types: Vec, + chains: Vec>, + temporal: TemporalOperator, + anchor_entity: Option, +} + +#[derive(Debug, Clone)] +struct RelationMatch { + relation: GraphRelationRecord, + hop: usize, +} + impl UnifiedMemory { - /// Ranked memory hits for a namespace (keyword + vector merge), with stored category per row. + /// Relation-first retrieval: + /// - graph relevance is the primary signal + /// - vector similarity is the secondary verification signal + /// - keyword overlap remains as a lexical backstop pub async fn query_namespace_ranked( &self, namespace: &str, query: &str, limit: u32, ) -> Result, String> { - let ns = Self::sanitize_namespace(namespace); - let query_terms: Vec = query - .split_whitespace() - .map(|t| t.trim().to_ascii_lowercase()) - .filter(|t| !t.is_empty()) - .collect(); - let (keyword_scores, by_key) = { - let conn = self.conn.lock(); - let mut stmt = conn - .prepare( - "SELECT key, content, category, updated_at FROM memory_docs - WHERE namespace = ?1 ORDER BY updated_at DESC LIMIT 400", - ) - .map_err(|e| format!("prepare query_namespace_context: {e}"))?; - let mut rows = stmt - .query(params![ns]) - .map_err(|e| format!("query query_namespace_context: {e}"))?; - let mut keyword_scores: HashMap = HashMap::new(); - let mut by_key: HashMap = HashMap::new(); - while let Some(row) = rows - .next() - .map_err(|e| format!("row query_namespace_context: {e}"))? - { - let key: String = row.get(0).map_err(|e| e.to_string())?; - let content: String = row.get(1).map_err(|e| e.to_string())?; - let category: String = row.get(2).map_err(|e| e.to_string())?; - let lower = format!("{key} {content}").to_ascii_lowercase(); - let matched = if query_terms.is_empty() { - 1 - } else { - query_terms - .iter() - .filter(|term| lower.contains(term.as_str())) - .count() - }; - if matched > 0 { - let score = matched as f64 / (query_terms.len().max(1) as f64); - keyword_scores.insert(key.clone(), score); - by_key.insert(key, (content, category)); - } + let hits = self.query_namespace_hits(namespace, query, limit).await?; + let mut out = Vec::new(); + for hit in hits { + if hit.kind != MemoryItemKind::Document { + continue; } - (keyword_scores, by_key) - }; + out.push(NamespaceQueryResult { + key: hit.key, + content: hit.content, + score: hit.score, + category: hit.category, + }); + } + Ok(out) + } - let vector_scores = self - .query_vector_scores(&ns, query) + pub async fn query_namespace_hits( + &self, + namespace: &str, + query: &str, + limit: u32, + ) -> Result, String> { + let ns = Self::sanitize_namespace(namespace); + let docs = self.load_documents_for_scope(&ns).await?; + let kvs = self.kv_records_for_scope(&ns).await?; + if docs.is_empty() && kvs.is_empty() { + return Ok(Vec::new()); + } + + let graph_relations = self + .graph_relations_for_scope(&ns) .await .unwrap_or_default(); - let mut merged: Vec = by_key - .into_iter() - .map(|(key, (content, category))| { - let k = keyword_scores.get(&key).copied().unwrap_or(0.0); - let v = vector_scores.get(&key).copied().unwrap_or(0.0); - NamespaceQueryResult { - key, - content, - score: 0.7 * v + 0.3 * k, - category, - } - }) - .collect(); - merged.sort_by(|a, b| { + let chunks = self.load_chunks_for_scope(&ns).await?; + let plan = self.build_retrieval_plan(query, &docs, &graph_relations); + let matched_relations = self.collect_relation_matches(&plan, &graph_relations); + let graph_scores = self.compute_graph_document_scores(&docs, &chunks, &matched_relations); + let vector_scores = self + .query_vector_scores_from_chunks(&chunks, query) + .await + .unwrap_or_default(); + let query_terms = plan.query_terms.clone(); + let now = Self::now_ts(); + + let has_graph_signal = graph_scores.values().any(|score| *score > 0.0); + let mut hits = Vec::new(); + + for doc in docs { + let keyword = self.keyword_score_for_text( + &query_terms, + &[doc.key.as_str(), doc.title.as_str(), doc.content.as_str()], + ); + let vector = vector_scores + .get(&doc.document_id) + .map(|(score, _)| *score) + .unwrap_or(0.0); + let graph = graph_scores.get(&doc.document_id).copied().unwrap_or(0.0); + let breakdown = if has_graph_signal { + Self::compose_query_score(keyword, vector, graph) + } else { + Self::compose_fallback_query_score(keyword, vector) + }; + if breakdown.final_score <= 0.0 { + continue; + } + + let best_chunk_id = vector_scores + .get(&doc.document_id) + .and_then(|(_, chunk_id)| chunk_id.clone()); + let supporting_relations = self.supporting_relations_for_document( + &doc.document_id, + &doc.content, + &matched_relations, + ); + + hits.push(NamespaceMemoryHit { + id: doc.document_id.clone(), + kind: MemoryItemKind::Document, + namespace: doc.namespace.clone(), + key: doc.key.clone(), + title: Some(doc.title.clone()), + content: doc.content.clone(), + category: doc.category.clone(), + source_type: Some(doc.source_type.clone()), + updated_at: doc.updated_at, + score: breakdown.final_score, + score_breakdown: breakdown, + document_id: Some(doc.document_id.clone()), + chunk_id: best_chunk_id, + supporting_relations, + }); + } + + for kv in kvs { + let rendered = Self::render_kv_value(&kv.value); + let keyword = + self.keyword_score_for_text(&query_terms, &[kv.key.as_str(), rendered.as_str()]); + if keyword <= 0.0 { + continue; + } + let freshness = Self::recency_score(kv.updated_at, now); + let final_score = (keyword * 0.8) + (freshness * 0.2); + hits.push(NamespaceMemoryHit { + id: format!( + "kv:{}:{}", + kv.namespace.as_deref().unwrap_or("global"), + kv.key + ), + kind: MemoryItemKind::Kv, + namespace: kv.namespace.unwrap_or_else(|| "global".to_string()), + key: kv.key, + title: None, + content: rendered, + category: "kv".to_string(), + source_type: None, + updated_at: kv.updated_at, + score: final_score, + score_breakdown: RetrievalScoreBreakdown { + keyword_relevance: keyword, + vector_similarity: 0.0, + graph_relevance: 0.0, + freshness, + final_score, + }, + document_id: None, + chunk_id: None, + supporting_relations: Vec::new(), + }); + } + + hits.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); - merged.truncate(limit as usize); - Ok(merged) + hits.truncate(limit as usize); + Ok(hits) } pub async fn query_namespace_context( @@ -89,51 +206,128 @@ impl UnifiedMemory { query: &str, limit: u32, ) -> Result { - let merged = self.query_namespace_ranked(namespace, query, limit).await?; - Ok(merged - .into_iter() - .map(|r| format!("{}: {}", r.key, r.content)) - .collect::>() - .join("\n\n")) + let context = self + .query_namespace_context_data(namespace, query, limit) + .await?; + Ok(context.context_text) } - async fn query_vector_scores( + pub async fn query_namespace_context_data( &self, namespace: &str, query: &str, - ) -> Result, String> { - let embedding = self - .embedder - .embed_one(query) + limit: u32, + ) -> Result { + let ns = Self::sanitize_namespace(namespace); + let hits = self.query_namespace_hits(&ns, query, limit).await?; + Ok(NamespaceRetrievalContext { + namespace: ns, + query: Some(query.to_string()), + context_text: Self::format_context_text(&hits, Some(query)), + hits, + }) + } + + pub async fn recall_namespace_memories( + &self, + namespace: &str, + limit: u32, + ) -> Result, String> { + let ns = Self::sanitize_namespace(namespace); + let docs = self.load_documents_for_scope(&ns).await?; + let kvs = self.kv_records_for_scope(&ns).await?; + let graph_relations = self + .graph_relations_for_scope(&ns) .await - .map_err(|e| format!("embedding query: {e}"))?; - let conn = self.conn.lock(); - let mut stmt = conn - .prepare( - "SELECT vc.embedding, md.key - FROM vector_chunks vc - JOIN memory_docs md ON md.document_id = vc.document_id AND md.namespace = vc.namespace - WHERE vc.namespace = ?1 AND vc.embedding IS NOT NULL", - ) - .map_err(|e| format!("prepare query_vector_scores: {e}"))?; - let mut rows = stmt - .query(params![namespace]) - .map_err(|e| format!("query query_vector_scores: {e}"))?; - let mut per_key = HashMap::new(); - while let Some(row) = rows - .next() - .map_err(|e| format!("row query_vector_scores: {e}"))? - { - let blob: Vec = row.get(0).map_err(|e| e.to_string())?; - let key: String = row.get(1).map_err(|e| e.to_string())?; - let chunk_vec = Self::bytes_to_vec(&blob); - let sim = Self::cosine_similarity(&embedding, &chunk_vec); - let old = per_key.get(&key).copied().unwrap_or(0.0); - if sim > old { - per_key.insert(key, sim); - } + .unwrap_or_default(); + let now = Self::now_ts(); + let mut hits = Vec::new(); + + for doc in docs { + let freshness = Self::recency_score(doc.updated_at, now); + let priority = Self::document_priority_signal( + &doc.category, + &doc.priority, + &doc.tags, + &doc.metadata, + ); + let graph = + self.document_recall_graph_signal(&doc.document_id, &doc.content, &graph_relations); + let final_score = (priority * RECALL_PRIORITY_WEIGHT) + + (graph * RECALL_GRAPH_WEIGHT) + + (freshness * RECALL_FRESHNESS_WEIGHT); + hits.push(NamespaceMemoryHit { + id: doc.document_id.clone(), + kind: MemoryItemKind::Document, + namespace: doc.namespace.clone(), + key: doc.key.clone(), + title: Some(doc.title.clone()), + content: doc.content.clone(), + category: doc.category.clone(), + source_type: Some(doc.source_type.clone()), + updated_at: doc.updated_at, + score: final_score, + score_breakdown: RetrievalScoreBreakdown { + keyword_relevance: priority, + vector_similarity: 0.0, + graph_relevance: graph, + freshness, + final_score, + }, + document_id: Some(doc.document_id.clone()), + chunk_id: None, + supporting_relations: self.supporting_relations_for_document( + &doc.document_id, + &doc.content, + &graph_relations + .iter() + .cloned() + .map(|relation| RelationMatch { relation, hop: 1 }) + .collect::>(), + ), + }); } - Ok(per_key) + + for kv in kvs { + let freshness = Self::recency_score(kv.updated_at, now); + let priority = Self::kv_priority_signal(&kv.key, &kv.value); + let final_score = + (priority * RECALL_PRIORITY_WEIGHT) + (freshness * (1.0 - RECALL_PRIORITY_WEIGHT)); + hits.push(NamespaceMemoryHit { + id: format!( + "kv:{}:{}", + kv.namespace.as_deref().unwrap_or("global"), + kv.key + ), + kind: MemoryItemKind::Kv, + namespace: kv.namespace.unwrap_or_else(|| "global".to_string()), + key: kv.key, + title: None, + content: Self::render_kv_value(&kv.value), + category: "kv".to_string(), + source_type: None, + updated_at: kv.updated_at, + score: final_score, + score_breakdown: RetrievalScoreBreakdown { + keyword_relevance: priority, + vector_similarity: 0.0, + graph_relevance: 0.0, + freshness, + final_score, + }, + document_id: None, + chunk_id: None, + supporting_relations: Vec::new(), + }); + } + + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + hits.truncate(limit as usize); + Ok(hits) } pub async fn recall_namespace_context( @@ -141,30 +335,887 @@ impl UnifiedMemory { namespace: &str, max_chunks: u32, ) -> Result, String> { + let hits = self + .recall_namespace_memories(namespace, max_chunks) + .await?; + if hits.is_empty() { + return Ok(None); + } + Ok(Some(Self::format_context_text(&hits, None))) + } + + pub async fn recall_namespace_context_data( + &self, + namespace: &str, + limit: u32, + ) -> Result { let ns = Self::sanitize_namespace(namespace); + let hits = self.recall_namespace_memories(&ns, limit).await?; + Ok(NamespaceRetrievalContext { + namespace: ns, + query: None, + context_text: Self::format_context_text(&hits, None), + hits, + }) + } + + async fn load_chunks_for_scope(&self, namespace: &str) -> Result, String> { let conn = self.conn.lock(); let mut stmt = conn .prepare( - "SELECT content FROM memory_docs - WHERE namespace = ?1 - ORDER BY updated_at DESC - LIMIT ?2", + "SELECT document_id, chunk_id, text, embedding, updated_at + FROM vector_chunks + WHERE namespace = ?1", ) - .map_err(|e| format!("prepare recall_namespace_context: {e}"))?; + .map_err(|e| format!("prepare load_chunks_for_scope: {e}"))?; let mut rows = stmt - .query(params![ns, i64::from(max_chunks)]) - .map_err(|e| format!("query recall_namespace_context: {e}"))?; - let mut out = Vec::new(); + .query(params![Self::sanitize_namespace(namespace)]) + .map_err(|e| format!("query load_chunks_for_scope: {e}"))?; + let mut chunks = Vec::new(); while let Some(row) = rows .next() - .map_err(|e| format!("row recall_namespace_context: {e}"))? + .map_err(|e| format!("row load_chunks_for_scope: {e}"))? { - out.push(row.get::<_, String>(0).map_err(|e| e.to_string())?); + let embedding_blob: Option> = row.get(3).map_err(|e| e.to_string())?; + chunks.push(StoredChunk { + document_id: row.get(0).map_err(|e| e.to_string())?, + chunk_id: row.get(1).map_err(|e| e.to_string())?, + text: row.get(2).map_err(|e| e.to_string())?, + embedding: embedding_blob.as_deref().map(Self::bytes_to_vec), + updated_at: row.get(4).map_err(|e| e.to_string())?, + }); } - if out.is_empty() { - Ok(None) - } else { - Ok(Some(out.join("\n\n"))) + Ok(chunks) + } + + async fn query_vector_scores_from_chunks( + &self, + chunks: &[StoredChunk], + query: &str, + ) -> Result)>, String> { + if chunks.is_empty() { + return Ok(HashMap::new()); + } + let query_embedding = self + .embedder + .embed_one(query) + .await + .map_err(|e| format!("embedding query: {e}"))?; + let mut scores = HashMap::new(); + for chunk in chunks { + let Some(embedding) = chunk.embedding.as_ref() else { + continue; + }; + let similarity = Self::cosine_similarity(&query_embedding, embedding); + let entry = scores + .entry(chunk.document_id.clone()) + .or_insert((0.0, None::)); + if similarity > entry.0 { + *entry = (similarity, Some(chunk.chunk_id.clone())); + } + } + Ok(scores) + } + + fn build_retrieval_plan( + &self, + query: &str, + docs: &[crate::openhuman::memory::store::types::StoredMemoryDocument], + graph_relations: &[GraphRelationRecord], + ) -> RetrievalPlan { + let query_terms = Self::tokenize_search_terms(query); + let temporal = Self::infer_temporal_operator(&query_terms); + let relation_types = Self::infer_relation_types(&query_terms); + let entity_candidates = self.match_query_entities(query, docs, graph_relations); + let anchor_entity = match temporal { + TemporalOperator::Before | TemporalOperator::After => { + self.resolve_anchor_entity(query, &entity_candidates) + } + _ => None, + }; + let seed_entities = entity_candidates + .into_iter() + .filter(|entity| anchor_entity.as_ref() != Some(entity)) + .collect::>(); + let chains = Self::infer_relation_chains(&query_terms, &relation_types); + + RetrievalPlan { + query_terms, + seed_entities, + relation_types, + chains, + temporal, + anchor_entity, } } + + fn match_query_entities( + &self, + query: &str, + docs: &[crate::openhuman::memory::store::types::StoredMemoryDocument], + graph_relations: &[GraphRelationRecord], + ) -> Vec { + let normalized_query = Self::normalize_search_text(query); + let mut entities = HashSet::new(); + + for relation in graph_relations { + for candidate in [&relation.subject, &relation.object] { + let normalized = Self::normalize_search_text(candidate); + if !normalized.is_empty() && normalized_query.contains(&normalized) { + entities.insert(candidate.clone()); + } + } + } + + for doc in docs { + for candidate in [&doc.key, &doc.title] { + let normalized = Self::normalize_search_text(candidate); + if !normalized.is_empty() && normalized_query.contains(&normalized) { + entities.insert(Self::normalize_graph_entity(candidate)); + } + } + } + + let mut out = entities.into_iter().collect::>(); + out.sort(); + out + } + + fn resolve_anchor_entity(&self, query: &str, entities: &[String]) -> Option { + let normalized_query = Self::normalize_search_text(query); + let mut best: Option<(usize, String)> = None; + for entity in entities { + let normalized_entity = Self::normalize_search_text(entity); + if normalized_entity.is_empty() { + continue; + } + if let Some(pos) = normalized_query.rfind(&normalized_entity) { + if best + .as_ref() + .map(|(best_pos, _)| pos > *best_pos) + .unwrap_or(true) + { + best = Some((pos, entity.clone())); + } + } + } + best.map(|(_, entity)| entity) + } + + fn collect_relation_matches( + &self, + plan: &RetrievalPlan, + graph_relations: &[GraphRelationRecord], + ) -> Vec { + let matches = self.direct_relation_matches(plan, graph_relations); + let chain_matches = self.multi_hop_relation_matches(plan, graph_relations); + let mut merged = matches; + for item in chain_matches { + let identity = Self::relation_identity(&item.relation); + if merged + .iter() + .any(|existing| Self::relation_identity(&existing.relation) == identity) + { + continue; + } + merged.push(item); + } + + let anchor_order = self.resolve_anchor_order(plan, graph_relations); + Self::apply_temporal_filter(plan, anchor_order, merged) + } + + fn direct_relation_matches( + &self, + plan: &RetrievalPlan, + graph_relations: &[GraphRelationRecord], + ) -> Vec { + let seed_entities = plan.seed_entities.iter().collect::>(); + graph_relations + .iter() + .filter(|relation| { + let touches_seed = seed_entities.is_empty() + || seed_entities.contains(&relation.subject) + || seed_entities.contains(&relation.object); + let predicate_match = plan.relation_types.is_empty() + || plan.relation_types.contains(&relation.predicate) + || Self::predicate_matches_query(&relation.predicate, &plan.query_terms); + let entity_overlap = seed_entities.is_empty() + || Self::relation_matches_terms(relation, &plan.query_terms); + touches_seed && predicate_match && entity_overlap + }) + .cloned() + .map(|relation| RelationMatch { relation, hop: 1 }) + .collect() + } + + fn multi_hop_relation_matches( + &self, + plan: &RetrievalPlan, + graph_relations: &[GraphRelationRecord], + ) -> Vec { + if plan.chains.is_empty() || plan.seed_entities.is_empty() { + return Vec::new(); + } + + let mut chain_results: Vec> = Vec::new(); + for chain in &plan.chains { + let mut frontier = plan.seed_entities.clone(); + let mut path = Vec::new(); + let mut used = HashSet::new(); + + for (hop_idx, step) in chain.iter().enumerate() { + let mut candidates = graph_relations + .iter() + .filter(|relation| { + relation.predicate == *step + && (frontier.contains(&relation.subject) + || frontier.contains(&relation.object)) + }) + .cloned() + .collect::>(); + + if candidates.is_empty() { + path.clear(); + break; + } + + candidates.sort_by(|a, b| { + Self::relation_order_value(b) + .cmp(&Self::relation_order_value(a)) + .then_with(|| { + b.updated_at + .partial_cmp(&a.updated_at) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + + let mut next_frontier = Vec::new(); + for relation in candidates { + let identity = Self::relation_identity(&relation); + if !used.insert(identity) { + continue; + } + if frontier.contains(&relation.subject) { + next_frontier.push(relation.object.clone()); + } + if frontier.contains(&relation.object) { + next_frontier.push(relation.subject.clone()); + } + path.push(RelationMatch { + relation, + hop: hop_idx + 2, + }); + } + + next_frontier.sort(); + next_frontier.dedup(); + frontier = next_frontier; + } + + if !path.is_empty() { + chain_results.push(path); + } + } + + if chain_results.is_empty() { + return Vec::new(); + } + + if plan.temporal == TemporalOperator::All { + return chain_results.into_iter().flatten().collect(); + } + + let choose_max = matches!( + plan.temporal, + TemporalOperator::Latest | TemporalOperator::Before + ); + chain_results + .into_iter() + .max_by(|a, b| { + let a_order = a + .iter() + .map(|item| Self::relation_order_value(&item.relation)) + .max() + .unwrap_or_default(); + let b_order = b + .iter() + .map(|item| Self::relation_order_value(&item.relation)) + .max() + .unwrap_or_default(); + if choose_max { + a_order.cmp(&b_order) + } else { + b_order.cmp(&a_order) + } + }) + .unwrap_or_default() + } + + fn apply_temporal_filter( + plan: &RetrievalPlan, + anchor_order: Option, + relations: Vec, + ) -> Vec { + if plan.temporal == TemporalOperator::All { + return relations; + } + + let filtered = match plan.temporal { + TemporalOperator::Before => relations + .into_iter() + .filter(|item| { + anchor_order + .map(|anchor| Self::relation_order_value(&item.relation) < anchor) + .unwrap_or(true) + }) + .collect::>(), + TemporalOperator::After => relations + .into_iter() + .filter(|item| { + anchor_order + .map(|anchor| Self::relation_order_value(&item.relation) > anchor) + .unwrap_or(true) + }) + .collect::>(), + _ => relations, + }; + + let mut groups: HashMap<(String, String), Vec> = HashMap::new(); + for item in filtered { + let pivot = if plan.seed_entities.contains(&item.relation.subject) { + item.relation.subject.clone() + } else if plan.seed_entities.contains(&item.relation.object) { + item.relation.object.clone() + } else { + item.relation.subject.clone() + }; + groups + .entry((pivot, item.relation.predicate.clone())) + .or_default() + .push(item); + } + + let mut out = Vec::new(); + for mut items in groups.into_values() { + items.sort_by(|a, b| { + Self::relation_order_value(&a.relation) + .cmp(&Self::relation_order_value(&b.relation)) + }); + match plan.temporal { + TemporalOperator::Earliest | TemporalOperator::After => { + if let Some(item) = items.into_iter().next() { + out.push(item); + } + } + TemporalOperator::Latest | TemporalOperator::Before => { + if let Some(item) = items.into_iter().last() { + out.push(item); + } + } + TemporalOperator::All => out.extend(items), + } + } + + out + } + + fn resolve_anchor_order( + &self, + plan: &RetrievalPlan, + graph_relations: &[GraphRelationRecord], + ) -> Option { + let anchor = plan.anchor_entity.as_ref()?; + let mut orders = graph_relations + .iter() + .filter(|relation| relation.subject == *anchor || relation.object == *anchor) + .map(Self::relation_order_value) + .collect::>(); + if orders.is_empty() { + return None; + } + orders.sort(); + match plan.temporal { + TemporalOperator::Before => orders.into_iter().max(), + TemporalOperator::After => orders.into_iter().min(), + _ => orders.into_iter().max(), + } + } + + fn compute_graph_document_scores( + &self, + docs: &[crate::openhuman::memory::store::types::StoredMemoryDocument], + chunks: &[StoredChunk], + relations: &[RelationMatch], + ) -> HashMap { + let mut doc_scores: HashMap = HashMap::new(); + let chunk_to_doc = chunks + .iter() + .map(|chunk| (chunk.chunk_id.clone(), chunk.document_id.clone())) + .collect::>(); + + for relation in relations { + let base = f64::from(relation.relation.evidence_count) / relation.hop.max(1) as f64; + for document_id in &relation.relation.document_ids { + *doc_scores.entry(document_id.clone()).or_insert(0.0) += base; + } + for chunk_id in &relation.relation.chunk_ids { + if let Some(document_id) = chunk_to_doc.get(chunk_id) { + *doc_scores.entry(document_id.clone()).or_insert(0.0) += base * 0.9; + } + } + + for doc in docs { + let normalized = Self::normalize_search_text(&doc.content); + let subject = Self::normalize_search_text(&relation.relation.subject); + let object = Self::normalize_search_text(&relation.relation.object); + if (!subject.is_empty() && normalized.contains(&subject)) + || (!object.is_empty() && normalized.contains(&object)) + { + *doc_scores.entry(doc.document_id.clone()).or_insert(0.0) += base * 0.35; + } + } + } + + Self::normalize_scores(doc_scores) + } + + fn supporting_relations_for_document( + &self, + document_id: &str, + content: &str, + relations: &[RelationMatch], + ) -> Vec { + let normalized_content = Self::normalize_search_text(content); + let mut out = relations + .iter() + .filter(|relation| { + relation + .relation + .document_ids + .iter() + .any(|id| id == document_id) + || relation + .relation + .chunk_ids + .iter() + .any(|chunk_id| chunk_id.starts_with(document_id)) + || normalized_content + .contains(&Self::normalize_search_text(&relation.relation.subject)) + || normalized_content + .contains(&Self::normalize_search_text(&relation.relation.object)) + }) + .map(|relation| relation.relation.clone()) + .collect::>(); + out.sort_by(|a, b| { + b.evidence_count.cmp(&a.evidence_count).then_with(|| { + b.updated_at + .partial_cmp(&a.updated_at) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + out.truncate(3); + out + } + + fn document_recall_graph_signal( + &self, + document_id: &str, + content: &str, + relations: &[GraphRelationRecord], + ) -> f64 { + let normalized_content = Self::normalize_search_text(content); + let mut score = 0.0; + for relation in relations { + if relation.document_ids.iter().any(|id| id == document_id) { + score += f64::from(relation.evidence_count); + continue; + } + let subject = Self::normalize_search_text(&relation.subject); + let object = Self::normalize_search_text(&relation.object); + if (!subject.is_empty() && normalized_content.contains(&subject)) + || (!object.is_empty() && normalized_content.contains(&object)) + { + score += f64::from(relation.evidence_count) * 0.35; + } + } + score.clamp(0.0, 10.0) / 10.0 + } + + fn keyword_score_for_text(&self, query_terms: &[String], text_parts: &[&str]) -> f64 { + if query_terms.is_empty() { + return 0.0; + } + let haystack = text_parts + .iter() + .map(|part| Self::normalize_search_text(part)) + .collect::>() + .join(" "); + if haystack.is_empty() { + return 0.0; + } + let matched = query_terms + .iter() + .filter(|term| haystack.contains(term.as_str())) + .count(); + matched as f64 / query_terms.len().max(1) as f64 + } + + fn compose_query_score( + keyword_relevance: f64, + vector_similarity: f64, + graph_relevance: f64, + ) -> RetrievalScoreBreakdown { + let final_score = (graph_relevance * GRAPH_WEIGHT) + + (vector_similarity * VECTOR_WEIGHT) + + (keyword_relevance * KEYWORD_WEIGHT); + RetrievalScoreBreakdown { + keyword_relevance, + vector_similarity, + graph_relevance, + freshness: 0.0, + final_score, + } + } + + fn compose_fallback_query_score( + keyword_relevance: f64, + vector_similarity: f64, + ) -> RetrievalScoreBreakdown { + let final_score = (vector_similarity * 0.65) + (keyword_relevance * 0.35); + RetrievalScoreBreakdown { + keyword_relevance, + vector_similarity, + graph_relevance: 0.0, + freshness: 0.0, + final_score, + } + } + + fn normalize_scores(scores: HashMap) -> HashMap { + let max_score = scores.values().copied().fold(0.0_f64, f64::max); + if max_score <= f64::EPSILON { + return HashMap::new(); + } + scores + .into_iter() + .map(|(key, score)| (key, (score / max_score).clamp(0.0, 1.0))) + .collect() + } + + fn infer_temporal_operator(query_terms: &[String]) -> TemporalOperator { + if query_terms.iter().any(|term| term == "before") { + TemporalOperator::Before + } else if query_terms.iter().any(|term| term == "after") { + TemporalOperator::After + } else if query_terms + .iter() + .any(|term| matches!(term.as_str(), "history" | "timeline" | "all")) + { + TemporalOperator::All + } else if query_terms + .iter() + .any(|term| matches!(term.as_str(), "first" | "earliest" | "initial")) + { + TemporalOperator::Earliest + } else { + TemporalOperator::Latest + } + } + + fn infer_relation_types(query_terms: &[String]) -> Vec { + let mut relation_types = HashSet::new(); + for term in query_terms { + match term.as_str() { + "where" | "location" | "located" | "place" => { + relation_types.insert("LOCATED_IN".to_string()); + relation_types.insert("RESIDES_AT".to_string()); + relation_types.insert("TRAVELS_TO".to_string()); + } + "owner" | "owns" | "owned" | "has" | "holding" => { + relation_types.insert("OWNS".to_string()); + relation_types.insert("USES".to_string()); + } + "works" | "employer" | "company" | "organization" => { + relation_types.insert("WORKS_FOR".to_string()); + } + "north" => { + relation_types.insert("NORTH_OF".to_string()); + } + "south" => { + relation_types.insert("SOUTH_OF".to_string()); + } + "east" => { + relation_types.insert("EAST_OF".to_string()); + } + "west" => { + relation_types.insert("WEST_OF".to_string()); + } + "give" | "gave" | "sent" | "handed" | "passed" | "received" | "receive" => { + relation_types.insert("USES".to_string()); + } + _ => {} + } + } + let mut out = relation_types.into_iter().collect::>(); + out.sort(); + out + } + + fn infer_relation_chains( + query_terms: &[String], + relation_types: &[String], + ) -> Vec> { + let mut chains = Vec::new(); + let asks_where = query_terms.iter().any(|term| term == "where"); + let transfer_like = query_terms.iter().any(|term| { + matches!( + term.as_str(), + "give" | "gave" | "sent" | "handed" | "passed" + ) + }); + + if asks_where { + chains.push(vec!["OWNS".to_string(), "TRAVELS_TO".to_string()]); + chains.push(vec!["USES".to_string(), "TRAVELS_TO".to_string()]); + chains.push(vec!["OWNS".to_string(), "LOCATED_IN".to_string()]); + chains.push(vec!["USES".to_string(), "LOCATED_IN".to_string()]); + } else if transfer_like { + chains.push(vec!["USES".to_string()]); + } else if !relation_types.is_empty() { + chains.push(relation_types.to_vec()); + } + + chains.truncate(4); + chains + } + + fn predicate_matches_query(predicate: &str, query_terms: &[String]) -> bool { + let normalized = Self::normalize_search_text(predicate); + query_terms.iter().any(|term| normalized.contains(term)) + } + + fn relation_matches_terms(relation: &GraphRelationRecord, query_terms: &[String]) -> bool { + let subject = Self::normalize_search_text(&relation.subject); + let object = Self::normalize_search_text(&relation.object); + let predicate = Self::normalize_search_text(&relation.predicate); + query_terms.iter().any(|term| { + subject.contains(term.as_str()) + || object.contains(term.as_str()) + || predicate.contains(term.as_str()) + }) + } + + fn relation_identity(relation: &GraphRelationRecord) -> String { + format!( + "{}|{}|{}|{}", + relation.namespace.as_deref().unwrap_or("global"), + relation.subject, + relation.predicate, + relation.object + ) + } + + fn relation_order_value(relation: &GraphRelationRecord) -> i64 { + relation + .order_index + .unwrap_or_else(|| relation.updated_at.round() as i64) + } + + fn document_priority_signal( + category: &str, + priority: &str, + tags: &[String], + metadata: &serde_json::Value, + ) -> f64 { + let mut score: f64 = 0.25; + if matches!(category, "core" | "conversation") { + score += 0.25; + } + if matches!(priority, "high" | "critical") { + score += 0.20; + } + if tags.iter().any(|tag| { + matches!( + tag.as_str(), + "decision" | "preference" | "owner" | "durable" | "profile" + ) + }) { + score += 0.20; + } + if metadata + .get("kind") + .and_then(serde_json::Value::as_str) + .map(|kind| matches!(kind, "decision" | "preference" | "profile")) + .unwrap_or(false) + { + score += 0.10; + } + score.clamp(0.0, 1.0) + } + + fn kv_priority_signal(key: &str, value: &serde_json::Value) -> f64 { + let key_norm = Self::normalize_search_text(key); + let value_norm = Self::normalize_search_text(&Self::render_kv_value(value)); + let mut score: f64 = 0.30; + if ["preference", "decision", "profile", "setting", "owner"] + .iter() + .any(|needle| key_norm.contains(needle) || value_norm.contains(needle)) + { + score += 0.35; + } + if value.is_object() || value.is_array() { + score += 0.15; + } + score.clamp(0.0, 1.0) + } + + fn render_kv_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } + } + + fn format_context_text(hits: &[NamespaceMemoryHit], query: Option<&str>) -> String { + let mut parts = Vec::new(); + if let Some(query) = query { + parts.push(format!("Query: {query}")); + } + for hit in hits { + let summary = match hit.kind { + MemoryItemKind::Document => { + let title = hit.title.clone().unwrap_or_else(|| hit.key.clone()); + format!("{title}: {}", hit.content.trim()) + } + MemoryItemKind::Kv => format!("[kv:{}] {}", hit.key, hit.content.trim()), + }; + parts.push(summary); + + if !hit.supporting_relations.is_empty() { + let relations = hit + .supporting_relations + .iter() + .map(|relation| { + format!( + "{} -[{}]-> {}", + relation.subject, relation.predicate, relation.object + ) + }) + .collect::>() + .join("; "); + parts.push(format!("Relations: {relations}")); + } + } + parts.join("\n\n") + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + use tempfile::TempDir; + + use crate::openhuman::memory::{ + embeddings::NoopEmbedding, NamespaceDocumentInput, UnifiedMemory, + }; + + #[tokio::test] + async fn graph_duplicate_upsert_aggregates_evidence_count() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + memory + .graph_upsert_namespace( + "team", + "alice", + "owns", + "atlas", + &json!({"document_id": "doc-1"}), + ) + .await + .unwrap(); + memory + .graph_upsert_namespace( + "team", + "ALICE", + "OWNS", + "ATLAS", + &json!({"document_ids": ["doc-2"], "evidence_count": 2}), + ) + .await + .unwrap(); + + let rows = memory.graph_relations_for_scope("team").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].subject, "ALICE"); + assert_eq!(rows[0].predicate, "OWNS"); + assert_eq!(rows[0].object, "ATLAS"); + assert_eq!(rows[0].evidence_count, 3); + assert_eq!(rows[0].document_ids, vec!["doc-1", "doc-2"]); + } + + #[tokio::test] + async fn query_namespace_uses_graph_signal_for_document_ranking() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + let document_id = memory + .upsert_document(NamespaceDocumentInput { + namespace: "team".to_string(), + key: "atlas-status".to_string(), + title: "Atlas status".to_string(), + content: "Project Atlas is currently owned by Alice.".to_string(), + source_type: "doc".to_string(), + priority: "high".to_string(), + tags: vec!["decision".to_string()], + metadata: json!({"kind": "decision"}), + category: "core".to_string(), + session_id: None, + document_id: None, + }) + .await + .unwrap(); + + memory + .graph_upsert_namespace( + "team", + "Alice", + "owns", + "Atlas", + &json!({"document_id": document_id}), + ) + .await + .unwrap(); + + let results = memory + .query_namespace_ranked("team", "who owns atlas", 5) + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].key, "atlas-status"); + assert!(results[0].score > 0.5); + } + + #[tokio::test] + async fn recall_namespace_memories_includes_namespace_kv() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + memory + .kv_set_namespace( + "team", + "user.preference.theme", + &json!({"value": "sunrise", "kind": "preference"}), + ) + .await + .unwrap(); + + let hits = memory.recall_namespace_memories("team", 5).await.unwrap(); + assert!(hits + .iter() + .any(|hit| matches!(hit.kind, crate::openhuman::memory::MemoryItemKind::Kv))); + } } diff --git a/src/rpc/dispatch.rs b/src/rpc/dispatch.rs index b80558bd4..7ead363fe 100644 --- a/src/rpc/dispatch.rs +++ b/src/rpc/dispatch.rs @@ -21,6 +21,92 @@ pub async fn try_dispatch( params: serde_json::Value, ) -> Option> { match method { + "memory.init" => Some( + async move { + let payload: crate::openhuman::memory::MemoryInitRequest = parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_init(payload).await?) + } + .await, + ), + + "memory.list_documents" => Some( + async move { + let payload: crate::openhuman::memory::ListDocumentsRequest = parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_list_documents(payload).await?) + } + .await, + ), + + "memory.list_namespaces" => Some( + async move { + let payload: crate::openhuman::memory::EmptyRequest = parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_list_namespaces(payload).await?) + } + .await, + ), + + "memory.delete_document" => Some( + async move { + let payload: crate::openhuman::memory::DeleteDocumentRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_delete_document(payload).await?) + } + .await, + ), + + "memory.query_namespace" => Some( + async move { + let payload: crate::openhuman::memory::QueryNamespaceRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_query_namespace(payload).await?) + } + .await, + ), + + "memory.recall_context" | "memory.recall_namespace" => Some( + async move { + let payload: crate::openhuman::memory::RecallContextRequest = parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_recall_context(payload).await?) + } + .await, + ), + + "memory.recall_memories" => Some( + async move { + let payload: crate::openhuman::memory::RecallMemoriesRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::memory_recall_memories(payload).await?) + } + .await, + ), + + "ai.list_memory_files" => Some( + async move { + let payload: crate::openhuman::memory::ListMemoryFilesRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::ai_list_memory_files(payload).await?) + } + .await, + ), + + "ai.read_memory_file" => Some( + async move { + let payload: crate::openhuman::memory::ReadMemoryFileRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::ai_read_memory_file(payload).await?) + } + .await, + ), + + "ai.write_memory_file" => Some( + async move { + let payload: crate::openhuman::memory::WriteMemoryFileRequest = + parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::ai_write_memory_file(payload).await?) + } + .await, + ), + "memory.namespace.list" => Some( async move { rpc_json(crate::openhuman::memory::rpc::namespace_list().await?) }.await, ), From 55cd5bef45b932470c675820bca0b4663c93a404 Mon Sep 17 00:00:00 2001 From: sanil jain Date: Tue, 31 Mar 2026 17:28:20 +0530 Subject: [PATCH 2/2] Fix Linux ONNX runtime loading for embeddings --- Cargo.lock | 631 +++-------------------------- Cargo.toml | 5 +- src/openhuman/memory/embeddings.rs | 83 ++++ 3 files changed, 136 insertions(+), 583 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4cccd48b..8c4658960 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,24 +87,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -205,29 +187,12 @@ dependencies = [ "object 0.37.3", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "archery" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "argon2" version = "0.5.3" @@ -255,15 +220,6 @@ dependencies = [ "serde", ] -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "as_variant" version = "1.3.0" @@ -408,49 +364,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - [[package]] name = "aws-lc-rs" version = "1.16.2" @@ -587,12 +500,6 @@ dependencies = [ "virtue", ] -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitfield" version = "0.19.4" @@ -634,15 +541,6 @@ version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - [[package]] name = "bitvec" version = "1.0.1" @@ -705,12 +603,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.20.2" @@ -749,12 +641,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -975,12 +861,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -1139,15 +1019,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "core_maths" version = "0.1.1" @@ -1229,12 +1100,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1463,16 +1328,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1841,26 +1696,6 @@ dependencies = [ "log", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1888,6 +1723,9 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] [[package]] name = "esp-idf-part" @@ -1967,21 +1805,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "eyeball" version = "0.8.8" @@ -2068,13 +1891,12 @@ checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" dependencies = [ "anyhow", "hf-hub", - "image", "ndarray", "ort", "safetensors", "serde", "serde_json", - "tokenizers", + "tokenizers 0.22.2", ] [[package]] @@ -2083,26 +1905,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "fd-lock" version = "4.0.4" @@ -2114,15 +1916,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2384,16 +2177,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gimli" version = "0.32.3" @@ -2467,17 +2250,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "hash32" version = "0.3.1" @@ -2651,12 +2423,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hmac-sha256" -version = "1.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" - [[package]] name = "home" version = "0.5.12" @@ -3054,40 +2820,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2" -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - [[package]] name = "imap-proto" version = "0.16.6" @@ -3121,12 +2853,6 @@ dependencies = [ "bitmaps", ] -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "impartial-ord" version = "1.0.6" @@ -3192,17 +2918,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "io-kit-sys" version = "0.4.1" @@ -3400,12 +3115,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "lettre" version = "0.11.19" @@ -3437,13 +3146,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] -name = "libfuzzer-sys" -version = "0.4.12" +name = "libloading" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "arbitrary", - "cc", + "cfg-if", + "windows-link", ] [[package]] @@ -3527,15 +3236,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lopdf" version = "0.38.0" @@ -3570,12 +3270,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" - [[package]] name = "mac" version = "0.1.1" @@ -4018,16 +3712,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "md-5" version = "0.10.6" @@ -4177,16 +3861,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "multimap" version = "0.10.1" @@ -4314,12 +3988,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -4339,16 +4007,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -4364,17 +4022,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -4384,17 +4031,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -4590,6 +4226,7 @@ dependencies = [ "log", "mail-parser", "matrix-sdk", + "ndarray", "nu-ansi-term 0.46.0", "nusb 0.2.3", "once_cell", @@ -4598,6 +4235,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "ort", "parking_lot", "pdf-extract", "postgres", @@ -4623,6 +4261,7 @@ dependencies = [ "socketioxide", "tempfile", "thiserror 2.0.18", + "tokenizers 0.21.4", "tokio", "tokio-rustls", "tokio-serial", @@ -4782,11 +4421,11 @@ version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" dependencies = [ + "libloading", "ndarray", "ort-sys", "smallvec", "tracing", - "ureq 3.3.0", ] [[package]] @@ -4794,11 +4433,6 @@ name = "ort-sys" version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" -dependencies = [ - "hmac-sha256", - "lzma-rust2", - "ureq 3.3.0", -] [[package]] name = "overload" @@ -4861,12 +4495,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -4894,15 +4522,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -5053,7 +4672,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", + "der", "spki", ] @@ -5063,19 +4682,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" @@ -5313,25 +4919,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "prometheus" version = "0.14.0" @@ -5466,27 +5053,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quinn" version = "0.11.9" @@ -5676,56 +5242,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - [[package]] name = "rawpointer" version = "0.2.1" @@ -5907,12 +5423,6 @@ dependencies = [ "webpki-roots 1.0.6", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "ring" version = "0.17.14" @@ -6671,15 +6181,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -6785,7 +6286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -7054,20 +6555,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.47" @@ -7133,6 +6620,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -7815,18 +7336,14 @@ checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", "cookie_store", - "der 0.8.0", "log", - "native-tls", "percent-encoding", "rustls", "rustls-pki-types", "serde", "serde_json", - "socks", "ureq-proto", "utf8-zero", - "webpki-root-certs", "webpki-roots 1.0.6", ] @@ -7897,17 +7414,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -8407,15 +7913,6 @@ dependencies = [ "url", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "0.26.11" @@ -8924,12 +8421,6 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yoke" version = "0.7.5" @@ -9104,27 +8595,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index 0a6b7dd28..45fa7e82b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,8 @@ anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" hex = "0.4" -fastembed = "5.13" +fastembed = { version = "5.13", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] } +ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", "ndarray", "load-dynamic"] } tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures = "0.3" @@ -62,6 +63,8 @@ dialoguer = { version = "0.12", features = ["fuzzy-select"] } console = "0.16" glob = "0.3" regex = "1.10" +ndarray = "0.17.2" +tokenizers = "0.21.0" hostname = "0.4.2" rustyline = { version = "15", features = ["with-file-history"] } rustls = { version = "0.23", features = ["ring"] } diff --git a/src/openhuman/memory/embeddings.rs b/src/openhuman/memory/embeddings.rs index bbbdf256f..111465354 100644 --- a/src/openhuman/memory/embeddings.rs +++ b/src/openhuman/memory/embeddings.rs @@ -1,10 +1,13 @@ use async_trait::async_trait; use parking_lot::Mutex; +use std::env; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; pub const DEFAULT_FASTEMBED_MODEL: &str = "BGESmallENV15"; pub const DEFAULT_FASTEMBED_DIMENSIONS: usize = 384; +const DEFAULT_MANAGED_RELEX_DIR: &str = ".openhuman/models/gliner-relex-large-v0.5-onnx"; /// Trait for embedding providers — convert text to vectors #[async_trait] @@ -81,6 +84,7 @@ impl FastembedEmbedding { } fn init_model(&self) -> anyhow::Result { + ensure_fastembed_ort_dylib_path(); fastembed::TextEmbedding::try_new( fastembed::InitOptions::new(self.resolve_model()).with_show_download_progress(false), ) @@ -88,6 +92,84 @@ impl FastembedEmbedding { } } +fn ensure_fastembed_ort_dylib_path() { + if env::var_os("ORT_DYLIB_PATH").is_some() { + return; + } + + if let Some(lib_path) = env::var_os("ORT_LIB_LOCATION") { + let candidate = PathBuf::from(lib_path); + if candidate.is_file() { + env::set_var("ORT_DYLIB_PATH", candidate); + return; + } + + #[cfg(target_os = "windows")] + let runtime_lib = candidate.join("onnxruntime.dll"); + #[cfg(target_os = "macos")] + let runtime_lib = candidate.join("libonnxruntime.dylib"); + #[cfg(target_os = "linux")] + let runtime_lib = candidate.join("libonnxruntime.so"); + + if runtime_lib.exists() { + env::set_var("ORT_DYLIB_PATH", runtime_lib); + return; + } + } + + if let Ok(path) = env::var("OPENHUMAN_GLINER_RELEX_CACHE_DIR") { + #[cfg(target_os = "windows")] + let bundled = PathBuf::from(path).join("onnxruntime.dll"); + #[cfg(target_os = "macos")] + let bundled = PathBuf::from(path).join("libonnxruntime.dylib"); + #[cfg(target_os = "linux")] + let bundled = PathBuf::from(path).join("libonnxruntime.so"); + + if bundled.exists() { + env::set_var("ORT_DYLIB_PATH", bundled); + return; + } + } + + if let Some(user_dirs) = directories::UserDirs::new() { + #[cfg(target_os = "windows")] + let bundled = user_dirs + .home_dir() + .join(DEFAULT_MANAGED_RELEX_DIR) + .join("onnxruntime.dll"); + #[cfg(target_os = "macos")] + let bundled = user_dirs + .home_dir() + .join(DEFAULT_MANAGED_RELEX_DIR) + .join("libonnxruntime.dylib"); + #[cfg(target_os = "linux")] + let bundled = user_dirs + .home_dir() + .join(DEFAULT_MANAGED_RELEX_DIR) + .join("libonnxruntime.so"); + + if bundled.exists() { + env::set_var("ORT_DYLIB_PATH", bundled); + return; + } + } + + #[cfg(target_os = "linux")] + { + for candidate in [ + "/usr/lib/x86_64-linux-gnu/libonnxruntime.so", + "/usr/local/lib/libonnxruntime.so", + "/usr/lib/libonnxruntime.so", + ] { + let candidate = PathBuf::from(candidate); + if candidate.exists() { + env::set_var("ORT_DYLIB_PATH", candidate); + return; + } + } + } +} + #[async_trait] impl EmbeddingProvider for FastembedEmbedding { fn name(&self) -> &str { @@ -110,6 +192,7 @@ impl EmbeddingProvider for FastembedEmbedding { let state = Arc::clone(&self.state); let provider = self.model.clone(); let join_result = tokio::task::spawn_blocking(move || { + ensure_fastembed_ort_dylib_path(); let mut guard = state.lock(); if matches!(*guard, FastembedState::Uninitialized) { match fastembed::TextEmbedding::try_new(