diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 789ca12a10..cd9ec722a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,40 @@ jobs: # Tests that need a display (Tauri) are skipped in headless CI via cfg - run: cargo test --workspace + # ── Integration tests against live PostgreSQL + Qdrant backends ──────────── + integration-backends: + name: Integration (Postgres + Qdrant) + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: openfang + POSTGRES_PASSWORD: openfang_dev + POSTGRES_DB: openfang + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U openfang" + --health-interval 2s + --health-timeout 5s + --health-retries 20 + qdrant: + image: qdrant/qdrant:latest + ports: + - 6333:6333 + - 6334:6334 + env: + TEST_POSTGRES_URL: postgresql://openfang:openfang_dev@localhost:5432/openfang + TEST_QDRANT_URL: http://localhost:6334 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: integration-backends + - run: cargo test -p openfang-memory --features postgres,qdrant --test backend_integration + clippy: name: Clippy runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index fb5aa5cc15..8d5be1c1fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -176,7 +176,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -212,9 +212,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -312,6 +312,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -377,9 +399,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -387,9 +409,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -399,11 +421,38 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ - "axum-core", + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", "base64 0.22.1", "bytes", "form_urlencoded", @@ -414,7 +463,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "multer", @@ -427,13 +476,33 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.28.0", - "tower", + "tokio-tungstenite 0.29.0", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -500,9 +569,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -522,7 +591,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -534,6 +603,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -634,7 +712,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cairo-sys-rs", "glib", "libc", @@ -721,9 +799,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -770,6 +848,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "charset" version = "0.1.5" @@ -810,15 +899,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -838,18 +927,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -872,6 +961,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cobs" version = "0.3.0" @@ -922,9 +1017,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "brotli", "compression-core", @@ -934,9 +1029,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -953,6 +1048,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.4.0" @@ -991,7 +1092,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-graphics-types", "foreign-types 0.5.0", @@ -1004,7 +1105,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "libc", ] @@ -1027,6 +1128,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.130.1" @@ -1252,7 +1362,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -1282,6 +1392,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -1341,6 +1460,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1348,9 +1476,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1368,14 +1496,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1391,13 +1543,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1418,9 +1581,44 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] [[package]] name = "debugid" @@ -1437,7 +1635,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -1462,6 +1660,37 @@ dependencies = [ "syn 2.0.117", ] +[[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 0.20.11", + "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 = "0.99.20" @@ -1502,11 +1731,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + [[package]] name = "directories-next" version = "2.0.0" @@ -1575,7 +1816,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -1686,7 +1927,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1715,14 +1956,14 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -1829,6 +2070,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1843,9 +2090,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -2130,7 +2377,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "debugid", "rustc-hash", "serde", @@ -2304,6 +2551,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -2320,13 +2568,13 @@ dependencies = [ [[package]] name = "gimli" -version = "0.33.1" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e16c5073773ccf057c282be832a59ee53ef5ff98db3aeff7f8314f52ffc196" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ "fnv", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -2368,7 +2616,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", @@ -2461,7 +2709,7 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand 0.9.2", + "rand 0.9.4", "smallvec", "spinning_top", "web-time", @@ -2519,6 +2767,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2558,6 +2825,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.9.1" @@ -2597,7 +2870,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", ] [[package]] @@ -2687,23 +2969,32 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2711,21 +3002,33 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2785,12 +3088,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2798,9 +3102,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2811,9 +3115,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2825,15 +3129,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2845,15 +3149,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2889,9 +3193,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2962,12 +3266,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -3006,7 +3310,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -3021,9 +3325,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3137,6 +3441,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -3177,10 +3511,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -3224,7 +3560,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "serde", "unicode-segmentation", ] @@ -3237,7 +3573,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser 0.29.6", "html5ever 0.29.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "selectors 0.24.0", ] @@ -3320,9 +3656,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -3342,14 +3678,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -3377,9 +3713,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -3500,12 +3836,28 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3586,9 +3938,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" dependencies = [ "crossbeam-channel", "dpi", @@ -3645,7 +3997,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3681,7 +4033,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -3721,9 +4073,9 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "notify-rust" -version = "4.12.0" +version = "4.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +checksum = "3bdaf6120b9df005d37e58f6b75329be6255450453fbeba9ce4192324f921fb9" dependencies = [ "futures-lite", "log", @@ -3757,6 +4109,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3795,7 +4157,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "objc2", "objc2-core-foundation", @@ -3808,7 +4170,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -3819,7 +4181,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -3847,7 +4209,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -3860,7 +4222,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -3871,7 +4233,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3883,19 +4245,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", "objc2-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3907,7 +4278,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "objc2", "objc2-app-kit", @@ -3923,7 +4294,7 @@ checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" dependencies = [ "crc32fast", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", ] @@ -3947,9 +4318,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ "dunce", "is-wsl", @@ -3963,14 +4334,14 @@ version = "0.6.4" dependencies = [ "argon2", "async-trait", - "axum", + "axum 0.8.9", "base64 0.22.1", "chrono", "dashmap", "futures", "governor", "hex", - "hmac", + "hmac 0.12.1", "openfang-channels", "openfang-extensions", "openfang-hands", @@ -3981,11 +4352,11 @@ dependencies = [ "openfang-skills", "openfang-types", "openfang-wire", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "socket2 0.5.10", "subtle", "tempfile", @@ -3993,7 +4364,7 @@ dependencies = [ "tokio-stream", "tokio-test", "toml 0.9.12+spec-1.1.0", - "tower", + "tower 0.5.3", "tower-http", "tracing", "uuid", @@ -4005,21 +4376,21 @@ version = "0.6.4" dependencies = [ "aes", "async-trait", - "axum", + "axum 0.8.9", "base64 0.22.1", "cbc", "chrono", "dashmap", "futures", "hex", - "hmac", + "hmac 0.12.1", "html-escape", "imap", "lettre", "mailparse", "native-tls", "openfang-types", - "prost", + "prost 0.14.3", "regex-lite", "reqwest 0.12.28", "roxmltree", @@ -4027,7 +4398,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.9", "tokio", "tokio-stream", "tokio-test", @@ -4070,7 +4441,7 @@ dependencies = [ name = "openfang-desktop" version = "0.6.4" dependencies = [ - "axum", + "axum 0.8.9", "open", "openfang-api", "openfang-kernel", @@ -4098,17 +4469,17 @@ version = "0.6.4" dependencies = [ "aes-gcm", "argon2", - "axum", + "axum 0.8.9", "base64 0.22.1", "chrono", "dashmap", "dirs 6.0.0", "openfang-types", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -4160,7 +4531,7 @@ dependencies = [ "openfang-skills", "openfang-types", "openfang-wire", - "rand 0.8.5", + "rand 0.8.6", "reqwest 0.12.28", "rustls", "serde", @@ -4183,15 +4554,22 @@ version = "0.6.4" dependencies = [ "async-trait", "chrono", + "deadpool-postgres", + "hex", "openfang-types", + "pgvector", + "qdrant-client", "reqwest 0.12.28", "rmp-serde", "rusqlite", "serde", "serde_json", + "sha2 0.10.9", + "sqlite-vec", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-postgres", "tokio-test", "tracing", "uuid", @@ -4238,7 +4616,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "shlex", "tempfile", "thiserror 2.0.18", @@ -4263,7 +4641,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -4280,16 +4658,16 @@ name = "openfang-types" version = "0.6.4" dependencies = [ "async-trait", - "bitflags 2.11.0", + "bitflags 2.11.1", "chrono", "dirs 6.0.0", "ed25519-dalek", "hex", - "rand 0.8.5", + "rand 0.8.6", "rmp-serde", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", "uuid", @@ -4303,12 +4681,12 @@ dependencies = [ "chrono", "dashmap", "hex", - "hmac", + "hmac 0.12.1", "openfang-types", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "subtle", "thiserror 2.0.18", "tokio", @@ -4319,11 +4697,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4351,18 +4729,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -4534,7 +4912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4544,7 +4922,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.13.0", + "indexmap 2.14.0", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "bytes", + "postgres-types", ] [[package]] @@ -4644,7 +5032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -4654,7 +5042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -4753,16 +5141,30 @@ dependencies = [ ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "pin-project" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" @@ -4787,9 +5189,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -4799,13 +5201,13 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml 0.38.4", + "indexmap 2.14.0", + "quick-xml 0.39.2", "serde", "time", ] @@ -4829,7 +5231,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", @@ -4857,7 +5259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4880,11 +5282,44 @@ dependencies = [ "serde", ] +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -4946,7 +5381,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -4995,13 +5430,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap 2.13.0", + "indexmap 2.14.0", "nix", "tokio", "tracing", "windows 0.62.2", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -5009,7 +5454,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5025,6 +5483,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "pulley-interpreter" version = "43.0.1" @@ -5050,9 +5517,31 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qdrant-client" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "f5d0a9b168ecf8f30a3eb7e8f4766e3050701242ffbe99838b58e6c4251e7211" +dependencies = [ + "anyhow", + "derive_builder", + "futures", + "futures-util", + "parking_lot", + "prost 0.13.5", + "prost-types", + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tonic", +] [[package]] name = "quanta" @@ -5080,9 +5569,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -5117,7 +5606,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -5186,9 +5675,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5197,14 +5686,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -5262,6 +5762,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -5295,7 +5801,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -5316,7 +5822,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -5327,9 +5833,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -5351,16 +5857,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -5407,13 +5913,13 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.15.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.5", + "hashbrown 0.17.0", "log", "rustc-hash", "smallvec", @@ -5465,6 +5971,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -5486,7 +5993,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5499,9 +6006,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", @@ -5527,7 +6034,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5577,9 +6084,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", "chrono", @@ -5587,7 +6094,7 @@ dependencies = [ "http", "pin-project-lite", "process-wrap", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "sse-stream", @@ -5651,8 +6158,8 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", + "bitflags 2.11.1", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -5668,9 +6175,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -5687,7 +6194,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5700,7 +6207,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5709,9 +6216,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -5735,11 +6242,20 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -5747,13 +6263,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -5774,9 +6290,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -5877,7 +6393,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -5918,7 +6434,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cssparser 0.36.0", "derive_more 2.1.1", "log", @@ -5933,9 +6449,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -6040,9 +6556,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -6069,7 +6585,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -6084,7 +6600,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6096,7 +6612,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -6151,8 +6667,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -6168,8 +6684,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -6255,6 +6782,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -6388,11 +6931,20 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba424237a9a5db2f6071f193319e2b6a32f7f3961debb2fbbfe67067abce3f" +dependencies = [ + "cc", +] + [[package]] name = "sse-stream" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" dependencies = [ "bytes", "futures-util", @@ -6462,6 +7014,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -6568,7 +7131,7 @@ version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "core-foundation", "core-graphics", @@ -6579,7 +7142,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -6652,7 +7215,7 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -6665,7 +7228,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.2", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -6725,7 +7288,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -6782,9 +7345,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" dependencies = [ "log", "raw-window-handle", @@ -6800,13 +7363,15 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.5" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation", "percent-encoding", "schemars 0.8.22", "serde", @@ -6843,7 +7408,7 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" dependencies = [ "log", "notify-rust", - "rand 0.9.2", + "rand 0.9.4", "serde", "serde_json", "serde_repr", @@ -6877,9 +7442,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1" dependencies = [ "serde", "serde_json", @@ -6892,9 +7457,9 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -6906,7 +7471,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.2", + "reqwest 0.13.3", "rustls", "semver", "serde", @@ -6933,7 +7498,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -6956,7 +7521,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -7014,13 +7579,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -7042,7 +7607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7160,9 +7725,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -7185,9 +7750,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -7202,9 +7767,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -7221,6 +7786,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2 0.6.3", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7271,14 +7862,14 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite 0.29.0", ] [[package]] @@ -7312,15 +7903,30 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", - "serde_spanned 1.1.0", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -7341,9 +7947,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -7354,7 +7960,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -7365,7 +7971,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -7374,30 +7980,84 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.2", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.2", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "flate2", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.5", + "rustls-native-certs", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.6", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] [[package]] name = "tower" @@ -7422,7 +8082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -7433,7 +8093,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -7566,7 +8226,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "rustls", "rustls-pki-types", "sha1", @@ -7576,19 +8236,18 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", - "utf-8", ] [[package]] @@ -7599,9 +8258,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -7667,12 +8326,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7714,7 +8394,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -7867,13 +8547,22 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -7882,14 +8571,23 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -7900,23 +8598,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7924,9 +8618,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -7937,9 +8631,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -7953,7 +8647,7 @@ dependencies = [ "anyhow", "heck 0.5.0", "im-rc", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "petgraph", "serde", @@ -7985,6 +8679,16 @@ dependencies = [ "wasmparser 0.245.1", ] +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", +] + [[package]] name = "wasm-metadata" version = "0.244.0" @@ -7992,7 +8696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -8029,9 +8733,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] @@ -8041,13 +8745,24 @@ version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", "serde", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmprinter" version = "0.245.1" @@ -8067,7 +8782,7 @@ checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" dependencies = [ "addr2line", "async-trait", - "bitflags 2.11.0", + "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", @@ -8125,7 +8840,7 @@ dependencies = [ "cranelift-entity", "gimli", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "object", "postcard", @@ -8133,7 +8848,7 @@ dependencies = [ "semver", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon 0.13.5", "wasm-encoder 0.245.1", @@ -8156,7 +8871,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "toml 0.9.12+spec-1.1.0", "wasmtime-environ", "windows-sys 0.61.2", @@ -8310,39 +9025,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "wit-parser 0.245.1", ] [[package]] name = "wast" -version = "245.0.1" +version = "248.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cf1149285569120b8ce39db8b465e8a2b55c34cbb586bd977e43e2bc7300bf" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.0", - "wasm-encoder 0.245.1", + "wasm-encoder 0.248.0", ] [[package]] name = "wat" -version = "1.245.1" +version = "1.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd48d1679b6858988cb96b154dda0ec5bbb09275b71db46057be37332d5477be" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" dependencies = [ "wast", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -8360,9 +9075,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", @@ -8416,18 +9131,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -8468,6 +9183,19 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8992,9 +9720,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -9027,6 +9755,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -9046,7 +9780,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -9076,8 +9810,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -9096,7 +9830,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -9115,7 +9849,7 @@ dependencies = [ "anyhow", "hashbrown 0.16.1", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -9127,9 +9861,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" @@ -9149,7 +9883,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -9161,7 +9895,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -9235,9 +9969,9 @@ version = "0.6.4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -9246,9 +9980,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -9258,9 +9992,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -9285,7 +10019,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.2", "zbus_macros", "zbus_names", "zvariant", @@ -9293,9 +10027,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9308,29 +10042,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.2", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -9339,18 +10073,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -9380,9 +10114,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -9391,9 +10125,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -9402,9 +10136,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -9420,7 +10154,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", "zopfli", ] @@ -9479,23 +10213,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.2", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -9506,13 +10240,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.2", ] diff --git a/Cargo.toml b/Cargo.toml index f9fe30e5bd..a3535c7df8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ uuid = { version = "1", features = ["v4", "v5", "serde"] } # Database rusqlite = { version = "0.31", features = ["bundled", "serde_json"] } +sqlite-vec = "0.1.6" # CLI clap = { version = "4", features = ["derive"] } diff --git a/Dockerfile b/Dockerfile index 1174e95085..9f9ea6d26f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,19 @@ # syntax=docker/dockerfile:1 + +# ── Test stage ────────────────────────────────────────────────────────────── +# Run: docker compose --profile test run test +FROM rust:1-slim-bookworm AS tester +WORKDIR /build +RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/* +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY xtask ./xtask +COPY agents ./agents +COPY packages ./packages +RUN cargo test --workspace --exclude openfang-desktop +RUN cargo clippy --workspace --all-targets --exclude openfang-desktop -- -D warnings + +# ── Builder stage ─────────────────────────────────────────────────────────── FROM rust:1-slim-bookworm AS builder WORKDIR /build RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/* diff --git a/MIGRATION.md b/MIGRATION.md index 6d3347698f..d222b65420 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -358,3 +358,206 @@ Then restart the daemon: ```bash openfang start ``` + +--- + +## Breaking Changes: Pluggable Memory Backends + +The pluggable-backends refactor in `openfang-memory` introduces a handful of +source-level breaking changes for anyone embedding the crate directly. The +OpenFang daemon, CLI, and config are unaffected. + +### PR review resolution — blockers + +Every blocker raised in the review is resolved in-tree. Each row below links +to the exact fix site. + +| # | Blocker (as reported) | Resolution | +|---|-----------------------|------------| +| B1 | `HttpSemanticStore::remember` returned a random `MemoryId::new()` instead of the server's ID; `forget`/`update_embedding` then delegated to a fallback with wrong IDs. | New `parse_memory_id()` helper at [crates/openfang-memory/src/http/semantic.rs:63](crates/openfang-memory/src/http/semantic.rs#L63) converts the server's `serde_json::Value` to a real `MemoryId`. `remember` at [L311](crates/openfang-memory/src/http/semantic.rs#L311) returns the parsed server UUID. `forget` / `update_embedding` now return an explicit `Err` rather than silently hitting a disconnected store. | +| B2 | `HttpSemanticStore::recall` fabricated a fresh `MemoryId::new()` per row; IDs were unstable across calls. | [crates/openfang-memory/src/http/semantic.rs:345](crates/openfang-memory/src/http/semantic.rs#L345) parses each server row's `r.id` via `parse_memory_id`; malformed rows are dropped with a `warn!` instead of being admitted with random UUIDs. IDs are now stable. | +| B3 | `MemorySubstrate::open_postgres` called `Handle::current().block_on(...)` — panics on nested current-thread runtime. | New `async fn open_async` in [crates/openfang-memory/src/substrate.rs](crates/openfang-memory/src/substrate.rs) does all Postgres init (pool build + probe + migrations) via natural `.await`. No `Handle::current().block_on` remains anywhere. Sync `open()` errors cleanly for Postgres backends. Kernel boot at [crates/openfang-kernel/src/kernel.rs:613](crates/openfang-kernel/src/kernel.rs#L613) routes through `open_async(...).await`. | +| B4 | Docs listed `qdrant` / `postgres+qdrant` as valid `semantic_backend` values, but `select_semantic` had no matching arm — unknown values silently fell through to SQLite. | Config fields are now the typed enums `MemoryBackendKind` / `SemanticBackendKind` in [crates/openfang-types/src/config.rs](crates/openfang-types/src/config.rs) with `#[serde(rename_all="snake_case")]` and `deny_unknown_fields` on `MemoryConfig` — typos are rejected at parse time. `PostgresSemanticStore` implemented in [crates/openfang-memory/src/postgres/semantic.rs](crates/openfang-memory/src/postgres/semantic.rs). `select_semantic` is an exhaustive enum match; feature-off paths return `OpenFangError::Config` rather than silently degrading. | +| B5 | Cargo.lock showed workspace `0.5.9 → 0.5.5` and dropped rustls from `openfang-kernel` — wrong-base artifact. | Branch rebased onto `origin/main` (tip `d3d9fa8` = v0.5.10). `cargo generate-lockfile` regenerated; all crates at `0.5.10`; rustls remains in `openfang-kernel`. | +| B6 | Breaking API changes without a migration note (`usage_conn()` removed, modules moved under `sqlite::`). | This file — sections below document every break: `usage_conn()` → `usage()`/`usage_arc()`, module path moves, new feature flags, `HttpSemanticStore::forget`/`update_embedding` error change, async `open_async`, typed config enums, `postgres_pool_size` validation, fail-fast semantics. | +| B7 | `AuditLog::with_backend` used `if let Ok(rows) = ...` and silently started with an empty log on `Err` — unacceptable for a security audit trail. | [crates/openfang-runtime/src/audit.rs:134](crates/openfang-runtime/src/audit.rs#L134) signature is now `-> OpenFangResult`. Load errors and integrity-check failures both `tracing::error!(...)` and propagate via `?` (fail-closed). Kernel caller surfaces the error as `KernelError::BootFailed`. | + +### PR review resolution — concerns + +| # | Concern (as reported) | Resolution | +|---|----------------------|------------| +| C1 | `unsafe` FFI registration for `sqlite-vec` in the production `SqliteBackend::open` path — the PR claim "no new unsafe" was inaccurate. | Both `unsafe` blocks in [crates/openfang-memory/src/sqlite/mod.rs](crates/openfang-memory/src/sqlite/mod.rs) (`open` and `open_in_memory`) now carry explicit `// SAFETY:` comments covering ABI compatibility, idempotency, and process-global semantics. The dedicated subsection below explicitly corrects the "no new unsafe" claim and names the two blocks. | +| C2 | `QdrantSemanticStore::recall` returned `Ok(vec![])` when `query_embedding` was `None`. | [crates/openfang-memory/src/qdrant/semantic.rs:213](crates/openfang-memory/src/qdrant/semantic.rs#L213) now returns `OpenFangError::Memory("Qdrant semantic backend requires a query_embedding for recall(); enable an embedder or use a different semantic_backend")`. The Postgres semantic backend mirrors the same contract. | +| C3 | JSONL mirror writes `sessions_dir.join(format!("{}.jsonl", session.id.0))` — needs documentation that `SessionId` is UUID-only. | [crates/openfang-memory/src/jsonl.rs:26](crates/openfang-memory/src/jsonl.rs#L26) has a comment citing `openfang_types::agent::SessionId` as a `uuid::Uuid` newtype, so the filename component is path-traversal safe. | +| C4 | `docker-compose.yml` hardcoded `POSTGRES_PASSWORD: openfang` — needs a dev-only marker. | Every config value in [docker-compose.yml](docker-compose.yml) is env-overridable: `POSTGRES_{IMAGE,USER,PASSWORD,DB,PORT}`, `QDRANT_{IMAGE,HTTP_PORT,GRPC_PORT}`, `OPENFANG_PORT`. Defaults are clearly dev/CI; a comment above the service warns "DO NOT reuse these credentials in production — every setting below is overridable via the environment." | +| C5 | Whether `openfang-kernel` / the binary crates enable `postgres` / `qdrant` features on `openfang-memory` wasn't visible — please confirm end-to-end wiring. | Feature-forwarding is declared in every crate: `openfang-memory` exposes `postgres` / `qdrant` / `http-memory`. [crates/openfang-kernel/Cargo.toml](crates/openfang-kernel/Cargo.toml) has `postgres = ["openfang-memory/postgres"]`, `qdrant = ["openfang-memory/qdrant"]`. [crates/openfang-cli/Cargo.toml](crates/openfang-cli/Cargo.toml) has matching `postgres = ["openfang-kernel/postgres"]`, `qdrant = ["openfang-kernel/qdrant"]`. Verified by building all four combinations (`--no-default-features`, `--features postgres`, `--features qdrant`, `--features postgres,qdrant`); a unit test (`feature_gated_backend_errors_cleanly_when_feature_off`) locks in the fail-fast behavior when a feature-gated backend is configured without its cargo feature. | + + +### `MemorySubstrate::usage_conn()` removed + +Use the trait-object accessors instead of reaching for a raw SQLite handle. + +**Before:** +```rust +let conn = memory.usage_conn(); +``` + +**After:** +```rust +// Borrowed trait object: +let usage: &dyn UsageBackend = memory.usage(); + +// Or an owned Arc for background tasks: +let usage: Arc = memory.usage_arc(); +``` + +### SQLite store modules moved under `openfang_memory::sqlite::` + +`consolidation`, `knowledge`, `semantic`, `session`, `structured`, `usage`, +`audit`, `paired_devices`, and `task_queue` now live under the `sqlite::` +submodule. The top-level re-exports are gone. + +**Before:** +```rust +use openfang_memory::knowledge::KnowledgeStore; +use openfang_memory::consolidation::ConsolidationEngine; +``` + +**After:** +```rust +use openfang_memory::sqlite::knowledge::KnowledgeStore; +use openfang_memory::sqlite::consolidation::ConsolidationEngine; +``` + +### New optional feature flags: `postgres`, `qdrant` + +`openfang-memory` now gates its PostgreSQL and Qdrant backends behind Cargo +features. `openfang-kernel` and `openfang-cli` forward matching feature names. +The SQLite backend remains the default and requires no feature flag. + +```toml +[dependencies] +openfang-memory = { version = "*", features = ["postgres", "qdrant"] } +``` + +### `HttpSemanticStore::forget` and `update_embedding` now return `Err` + +Previously both methods silently fell through to the local SQLite fallback +and fabricated IDs on miss. They now return an explicit error on unknown IDs +instead of masking the failure. Callers that relied on the silent fallback +must handle the error path. + +--- + +## Typed memory backend config (replaces string backend names) + +`MemoryConfig::backend` and `MemoryConfig::semantic_backend` are now typed +enums instead of `String` / `Option`. This is a source-level breaking +change for Rust code that constructs `MemoryConfig` directly. Configuration +files are unchanged. + +### TOML is unchanged + +- Both enums use `#[serde(rename_all = "snake_case")]`, so existing values + (`"sqlite"`, `"postgres"`, `"qdrant"`, `"http"`) deserialize as before. +- No edits to `~/.openfang/config.toml` are required. + +### Rust construction + +**Before:** +```rust +MemoryConfig { + backend: "postgres".to_string(), + semantic_backend: Some("qdrant".to_string()), + ..Default::default() +} +``` + +**After:** +```rust +use openfang_memory::{MemoryBackendKind, SemanticBackendKind}; +// also re-exported at openfang_types::config::{MemoryBackendKind, SemanticBackendKind} + +MemoryConfig { + backend: MemoryBackendKind::Postgres, + semantic_backend: Some(SemanticBackendKind::Qdrant), + ..Default::default() +} +``` + +The `Kind` suffix disambiguates these config enums from the `SemanticBackend` +and `StructuredBackend` *traits* defined in `openfang_types::storage`. + +### Fail-fast backend initialization + +- Qdrant unreachable, HTTP gateway health check failure, or Postgres + connection failure now exit the daemon at boot with a readable error. +- Previous builds silently degraded to SQLite on these failures. +- If silent SQLite was the desired behavior, set `backend = "sqlite"` (and + omit or set `semantic_backend = "sqlite"`) explicitly. + +### New validation: `postgres_pool_size` + +- Must be in `1..=1000`. Zero or out-of-range values are rejected at config + load time. + +### Memory backend connection configs are nested (TOML unchanged) + +Per-backend connection fields previously lived as flat fields on +`MemoryConfig`. They are now grouped into typed structs behind +`#[serde(flatten)]`, so existing `~/.openfang/config.toml` files keep working +unchanged. + +- `postgres_url` now lives on `PostgresConnConfig` under `postgres:`. +- `qdrant_url`, `qdrant_api_key_env`, `qdrant_collection` now live on + `QdrantConnConfig` under `qdrant:`. +- `http_url`, `http_token_env` now live on `HttpMemoryConnConfig` under + `http:`. +- `postgres_pool_size` stays at the top level of `MemoryConfig` (its validation + bounds live there). + +**Before:** +```rust +MemoryConfig { + postgres_url: Some("postgres://...".into()), + ..Default::default() +} +``` + +**After:** +```rust +use openfang_types::config::{PostgresConnConfig, QdrantConnConfig, HttpMemoryConnConfig}; + +MemoryConfig { + postgres: PostgresConnConfig { postgres_url: Some("postgres://...".into()) }, + ..Default::default() +} +``` + +The new structs are re-exported from +`openfang_types::config::{PostgresConnConfig, QdrantConnConfig, HttpMemoryConnConfig}`. + +### New direct dependencies — supply-chain notes + +The pluggable-memory work adds these direct dependencies. All match the +canonical publisher / crate name; none are typosquats: + +| Crate | Purpose | Notes | +|-------|---------|-------| +| `sqlite-vec` (pre-v1) | SQLite vector extension for fast semantic recall | Actively maintained (v0.1.9, released 2026-03-31). Sponsored by Mozilla, Fly.io, Turso, SQLite Cloud, Shinkai. Supports Linux, macOS, Windows, WASM per upstream README. Our CI matrix (`.github/workflows/ci.yml`) builds + tests on `ubuntu-latest`, `macos-latest`, `windows-latest` — Windows compile regressions would be caught there. Release matrix (`.github/workflows/release.yml`) produces artifacts for the same three platforms. The upstream `pre-v1` warning means we should be careful when bumping the minor version; patch bumps within `0.1.x` are accepted by the default semver caret. | +| `hex`, `sha2` | Audit-chain hashing | Standard, widely audited. | +| `tokio-postgres`, `deadpool-postgres`, `pgvector` | Postgres backend (feature-gated) | Canonical Rust Postgres stack; `pgvector` is the crate maintained by pgvector's author. | +| `qdrant-client` | Qdrant backend (feature-gated) | Official Qdrant Rust client. | + +The transitive set (`tonic`, `prost-types`, `hyper-timeout`, `deadpool`, +`stringprep`, etc.) is what you'd expect for gRPC + Postgres and contains +no surprises. + +### `unsafe` FFI addition: `sqlite-vec` extension registration + +- `SqliteBackend::open` and `SqliteBackend::open_in_memory` each contain one + `unsafe` block that calls `rusqlite::ffi::sqlite3_auto_extension` with a + transmuted pointer to `sqlite_vec::sqlite3_vec_init`. This is the + documented registration pattern for the `sqlite-vec` crate — both blocks + carry `// SAFETY:` comments explaining the ABI-compatible transmute, + idempotency, and process-global semantics. diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 2336777775..41e0bf18a2 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -5478,10 +5478,10 @@ pub async fn agent_budget_status( }; let quota = &entry.manifest.resources; - let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); - let hourly = usage_store.query_hourly(agent_id).unwrap_or(0.0); - let daily = usage_store.query_daily(agent_id).unwrap_or(0.0); - let monthly = usage_store.query_monthly(agent_id).unwrap_or(0.0); + let usage = state.kernel.memory.usage(); + let hourly = usage.query_hourly(agent_id).unwrap_or(0.0); + let daily = usage.query_daily(agent_id).unwrap_or(0.0); + let monthly = usage.query_monthly(agent_id).unwrap_or(0.0); // Token usage from scheduler let token_usage = state.kernel.scheduler.get_usage(agent_id); @@ -5518,14 +5518,14 @@ pub async fn agent_budget_status( /// GET /api/budget/agents — Per-agent cost ranking (top spenders). pub async fn agent_budget_ranking(State(state): State>) -> impl IntoResponse { - let usage_store = openfang_memory::usage::UsageStore::new(state.kernel.memory.usage_conn()); + let usage = state.kernel.memory.usage(); let agents: Vec = state .kernel .registry .list() .iter() .filter_map(|entry| { - let daily = usage_store.query_daily(entry.id).unwrap_or(0.0); + let daily = usage.query_daily(entry.id).unwrap_or(0.0); if daily > 0.0 { Some(serde_json::json!({ "agent_id": entry.id.to_string(), diff --git a/crates/openfang-cli/Cargo.toml b/crates/openfang-cli/Cargo.toml index 073fea5bde..f5cc1cb26e 100644 --- a/crates/openfang-cli/Cargo.toml +++ b/crates/openfang-cli/Cargo.toml @@ -9,6 +9,11 @@ description = "CLI tool for the OpenFang Agent OS" name = "openfang" path = "src/main.rs" +[features] +default = [] +postgres = ["openfang-kernel/postgres"] +qdrant = ["openfang-kernel/qdrant"] + [dependencies] openfang-types = { path = "../openfang-types" } openfang-kernel = { path = "../openfang-kernel" } diff --git a/crates/openfang-desktop/gen/schemas/acl-manifests.json b/crates/openfang-desktop/gen/schemas/acl-manifests.json index 298299746b..eda882bb0d 100644 --- a/crates/openfang-desktop/gen/schemas/acl-manifests.json +++ b/crates/openfang-desktop/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"autostart":{"default_permission":{"identifier":"default","description":"This permission set configures if your\napplication can enable or disable auto\nstarting the application on boot.\n\n#### Granted Permissions\n\nIt allows all to check, enable and\ndisable the automatic start on boot.\n\n","permissions":["allow-enable","allow-disable","allow-is-enabled"]},"permissions":{"allow-disable":{"identifier":"allow-disable","description":"Enables the disable command without any pre-configured scope.","commands":{"allow":["disable"],"deny":[]}},"allow-enable":{"identifier":"allow-enable","description":"Enables the enable command without any pre-configured scope.","commands":{"allow":["enable"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"deny-disable":{"identifier":"deny-disable","description":"Denies the disable command without any pre-configured scope.","commands":{"allow":[],"deny":["disable"]}},"deny-enable":{"identifier":"deny-enable","description":"Denies the enable command without any pre-configured scope.","commands":{"allow":[],"deny":["enable"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"global-shortcut":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n","permissions":[]},"permissions":{"allow-is-registered":{"identifier":"allow-is-registered","description":"Enables the is_registered command without any pre-configured scope.","commands":{"allow":["is_registered"],"deny":[]}},"allow-register":{"identifier":"allow-register","description":"Enables the register command without any pre-configured scope.","commands":{"allow":["register"],"deny":[]}},"allow-register-all":{"identifier":"allow-register-all","description":"Enables the register_all command without any pre-configured scope.","commands":{"allow":["register_all"],"deny":[]}},"allow-unregister":{"identifier":"allow-unregister","description":"Enables the unregister command without any pre-configured scope.","commands":{"allow":["unregister"],"deny":[]}},"allow-unregister-all":{"identifier":"allow-unregister-all","description":"Enables the unregister_all command without any pre-configured scope.","commands":{"allow":["unregister_all"],"deny":[]}},"deny-is-registered":{"identifier":"deny-is-registered","description":"Denies the is_registered command without any pre-configured scope.","commands":{"allow":[],"deny":["is_registered"]}},"deny-register":{"identifier":"deny-register","description":"Denies the register command without any pre-configured scope.","commands":{"allow":[],"deny":["register"]}},"deny-register-all":{"identifier":"deny-register-all","description":"Denies the register_all command without any pre-configured scope.","commands":{"allow":[],"deny":["register_all"]}},"deny-unregister":{"identifier":"deny-unregister","description":"Denies the unregister command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister"]}},"deny-unregister-all":{"identifier":"deny-unregister-all","description":"Denies the unregister_all command without any pre-configured scope.","commands":{"allow":[],"deny":["unregister_all"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/openfang-desktop/gen/schemas/desktop-schema.json b/crates/openfang-desktop/gen/schemas/desktop-schema.json index c1dc14d8cf..dc66bb7b9e 100644 --- a/crates/openfang-desktop/gen/schemas/desktop-schema.json +++ b/crates/openfang-desktop/gen/schemas/desktop-schema.json @@ -2397,22 +2397,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope.", + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope." + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { - "description": "Enables the confirm command without any pre-configured scope.", + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope." + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { "description": "Enables the message command without any pre-configured scope.", @@ -2433,16 +2433,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope.", + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope." + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { - "description": "Denies the confirm command without any pre-configured scope.", + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope." + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/crates/openfang-desktop/gen/schemas/macOS-schema.json b/crates/openfang-desktop/gen/schemas/macOS-schema.json index c1dc14d8cf..dc66bb7b9e 100644 --- a/crates/openfang-desktop/gen/schemas/macOS-schema.json +++ b/crates/openfang-desktop/gen/schemas/macOS-schema.json @@ -2397,22 +2397,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope.", + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope." + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { - "description": "Enables the confirm command without any pre-configured scope.", + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope." + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" }, { "description": "Enables the message command without any pre-configured scope.", @@ -2433,16 +2433,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope.", + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope." + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { - "description": "Denies the confirm command without any pre-configured scope.", + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope." + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/crates/openfang-kernel/Cargo.toml b/crates/openfang-kernel/Cargo.toml index efbcd975a3..48a115ce81 100644 --- a/crates/openfang-kernel/Cargo.toml +++ b/crates/openfang-kernel/Cargo.toml @@ -5,6 +5,11 @@ edition.workspace = true license.workspace = true description = "Core kernel for the OpenFang Agent OS" +[features] +default = [] +postgres = ["openfang-memory/postgres"] +qdrant = ["openfang-memory/qdrant"] + [dependencies] openfang-types = { path = "../openfang-types" } openfang-memory = { path = "../openfang-memory" } diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index c91d02a85c..f7f65754ed 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -620,9 +620,38 @@ impl OpenFangKernel { .sqlite_path .clone() .unwrap_or_else(|| config.data_dir.join("openfang.db")); + // Open the memory substrate asynchronously. `boot_with_config` is a + // sync fn, but when invoked from the daemon/desktop it runs inside an + // active tokio runtime. We detect that case and drive the async open + // on a scoped thread via the existing Handle (to avoid a nested + // `block_on` panic); otherwise we spin up a short-lived runtime so + // bare sync callers (tests, simple CLI paths) still work. + let memory_db_path = db_path.clone(); + let memory_config = config.memory.clone(); + let open_fut = async move { + MemorySubstrate::open_async(&memory_db_path, memory_config.decay_rate, &memory_config) + .await + }; let memory = Arc::new( - MemorySubstrate::open(&db_path, config.memory.decay_rate, &config.memory) - .map_err(|e| KernelError::BootFailed(format!("Memory init failed: {e}")))?, + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::thread::scope(|s| { + s.spawn(|| handle.block_on(open_fut)) + .join() + .unwrap_or_else(|_| { + Err(openfang_types::error::OpenFangError::Memory( + "Memory substrate open thread panicked".into(), + )) + }) + }) + } else { + let rt = tokio::runtime::Runtime::new().map_err(|e| { + openfang_types::error::OpenFangError::Memory(format!( + "Failed to create tokio runtime for memory open: {e}" + )) + })?; + rt.block_on(open_fut) + } + .map_err(|e| KernelError::BootFailed(format!("Memory init failed: {e}")))?, ); // Initialize credential resolver (vault → dotenv → env var) @@ -780,9 +809,7 @@ impl OpenFangKernel { }; // Initialize metering engine (shares the same SQLite connection as the memory substrate) - let metering = Arc::new(MeteringEngine::new(Arc::new( - openfang_memory::usage::UsageStore::new(memory.usage_conn()), - ))); + let metering = Arc::new(MeteringEngine::new(memory.usage_arc())); let supervisor = Supervisor::new(); let background = BackgroundExecutor::new(supervisor.subscribe()); @@ -1135,6 +1162,16 @@ impl OpenFangKernel { let initial_broadcast = config.broadcast.clone(); let auto_reply_engine = crate::auto_reply::AutoReplyEngine::new(config.auto_reply.clone()); + // Build the audit log. `with_backend` fails-closed on load/integrity + // errors so we propagate them as a boot failure rather than silently + // starting with an empty or corrupt chain. + let audit_log = Arc::new(match memory.audit() { + Some(backend) => AuditLog::with_backend(backend).map_err(|e| { + KernelError::BootFailed(format!("Audit log init failed: {e}")) + })?, + None => AuditLog::new(), + }); + let kernel = Self { config, registry: AgentRegistry::new(), @@ -1146,7 +1183,7 @@ impl OpenFangKernel { workflows: WorkflowEngine::new(), triggers: TriggerEngine::new(), background, - audit_log: Arc::new(AuditLog::with_db(memory.usage_conn())), + audit_log, metering, default_driver: driver, wasm_sandbox, diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index e34dff4e97..f6ef32a596 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -1,19 +1,20 @@ //! Metering engine — tracks LLM cost and enforces spending quotas. -use openfang_memory::usage::{ModelUsage, UsageRecord, UsageStore, UsageSummary}; +use openfang_memory::backends::UsageBackend; +use openfang_memory::usage::{ModelUsage, UsageRecord, UsageSummary}; use openfang_types::agent::{AgentId, ResourceQuota}; use openfang_types::error::{OpenFangError, OpenFangResult}; use std::sync::Arc; /// The metering engine tracks usage cost and enforces quota limits. pub struct MeteringEngine { - /// Persistent usage store (SQLite-backed). - store: Arc, + /// Persistent usage store. + store: Arc, } impl MeteringEngine { /// Create a new metering engine with the given usage store. - pub fn new(store: Arc) -> Self { + pub fn new(store: Arc) -> Self { Self { store } } @@ -518,8 +519,7 @@ mod tests { fn setup() -> MeteringEngine { let substrate = MemorySubstrate::open_in_memory(0.1).unwrap(); - let store = Arc::new(UsageStore::new(substrate.usage_conn())); - MeteringEngine::new(store) + MeteringEngine::new(substrate.usage_arc()) } #[test] diff --git a/crates/openfang-memory/Cargo.toml b/crates/openfang-memory/Cargo.toml index 2ad9ee6b69..8e93317b0c 100644 --- a/crates/openfang-memory/Cargo.toml +++ b/crates/openfang-memory/Cargo.toml @@ -8,6 +8,8 @@ description = "Memory substrate for the OpenFang Agent OS" [features] default = ["http-memory"] http-memory = ["reqwest"] +postgres = ["dep:tokio-postgres", "dep:deadpool-postgres", "dep:pgvector"] +qdrant = ["dep:qdrant-client"] [dependencies] openfang-types = { path = "../openfang-types" } @@ -16,12 +18,19 @@ serde = { workspace = true } serde_json = { workspace = true } rmp-serde = { workspace = true } rusqlite = { workspace = true } +sqlite-vec = { workspace = true } chrono = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } reqwest = { workspace = true, features = ["blocking"], optional = true } +tokio-postgres = { version = "0.7.17", optional = true, features = ["with-serde_json-1", "with-chrono-0_4", "with-uuid-1"] } +deadpool-postgres = { version = "0.14", optional = true } +pgvector = { version = "0.4", optional = true, features = ["postgres"] } +qdrant-client = { version = "1.13", optional = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/openfang-memory/src/backends.rs b/crates/openfang-memory/src/backends.rs new file mode 100644 index 0000000000..ea55337ca0 --- /dev/null +++ b/crates/openfang-memory/src/backends.rs @@ -0,0 +1,215 @@ +//! Backend traits for session and usage stores. +//! +//! These traits reference types defined in this crate (`Session`, `UsageRecord`, etc.) +//! and therefore cannot live in `openfang-types`. The structured, semantic, and +//! knowledge backend traits are in `openfang_types::storage`. + +use chrono::Utc; +use openfang_types::agent::{AgentId, SessionId}; +use openfang_types::error::OpenFangResult; +use openfang_types::message::Message; + +use crate::session::{CanonicalSession, Session}; +use crate::usage::{DailyBreakdown, ModelUsage, UsageRecord, UsageSummary}; + +// Re-export the traits from openfang-types for convenience. +pub use openfang_types::storage::{KnowledgeBackend, SemanticBackend, StructuredBackend}; + +/// Backend for conversation session persistence. +pub trait SessionBackend: Send + Sync { + /// Get a session by ID. + fn get_session(&self, id: SessionId) -> OpenFangResult>; + /// Save (upsert) a session. + fn save_session(&self, session: &Session) -> OpenFangResult<()>; + /// Delete a session. + fn delete_session(&self, id: SessionId) -> OpenFangResult<()>; + /// Delete all sessions for an agent. + fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()>; + /// Create a new empty session for an agent. + fn create_session(&self, agent_id: AgentId) -> OpenFangResult { + self.create_session_with_label(agent_id, None) + } + /// Create a new session with an optional label. + fn create_session_with_label( + &self, + agent_id: AgentId, + label: Option<&str>, + ) -> OpenFangResult { + let session = Session { + id: SessionId::new(), + agent_id, + messages: vec![], + context_window_tokens: 0, + label: label.map(|s| s.to_string()), + }; + self.save_session(&session)?; + Ok(session) + } + /// List all sessions (metadata only). + fn list_sessions(&self) -> OpenFangResult>; + /// List sessions for a specific agent. + fn list_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult>; + /// Set a human-readable label on a session. + fn set_session_label(&self, id: SessionId, label: Option<&str>) -> OpenFangResult<()>; + /// Find a session by label for a given agent. + fn find_session_by_label( + &self, + agent_id: AgentId, + label: &str, + ) -> OpenFangResult>; + /// Delete the canonical session for an agent. + fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()>; + + // -- Canonical session methods -- + + /// Load the canonical (cross-channel) session, creating if absent. + fn load_canonical(&self, agent_id: AgentId) -> OpenFangResult; + /// Persist a canonical session (insert or update). + fn save_canonical(&self, canonical: &CanonicalSession) -> OpenFangResult<()>; + /// Append messages to the canonical session, compacting if needed. + fn append_canonical( + &self, + agent_id: AgentId, + new_messages: &[Message], + compaction_threshold: Option, + ) -> OpenFangResult { + let mut canonical = self.load_canonical(agent_id)?; + canonical.messages.extend_from_slice(new_messages); + + let threshold = compaction_threshold.unwrap_or(100); + let keep_count = 50; + + if canonical.messages.len() > threshold { + let to_compact = canonical.messages.len().saturating_sub(keep_count); + if to_compact > canonical.compaction_cursor { + let compacting = &canonical.messages[canonical.compaction_cursor..to_compact]; + let mut summary_parts: Vec = Vec::new(); + if let Some(ref existing) = canonical.compacted_summary { + summary_parts.push(existing.clone()); + } + for msg in compacting { + let role = match msg.role { + openfang_types::message::Role::User => "User", + openfang_types::message::Role::Assistant => "Assistant", + openfang_types::message::Role::System => "System", + }; + let text = msg.content.text_content(); + if !text.is_empty() { + let truncated = if text.len() > 200 { + format!("{}...", openfang_types::truncate_str(&text, 200)) + } else { + text + }; + summary_parts.push(format!("{role}: {truncated}")); + } + } + let mut full_summary = summary_parts.join("\n"); + if full_summary.len() > 4000 { + let start = full_summary.len() - 4000; + let safe_start = (start..full_summary.len()) + .find(|&i| full_summary.is_char_boundary(i)) + .unwrap_or(full_summary.len()); + full_summary = full_summary[safe_start..].to_string(); + } + canonical.compacted_summary = Some(full_summary); + canonical.messages = canonical.messages.split_off(to_compact); + canonical.compaction_cursor = 0; + } + } + + canonical.updated_at = Utc::now().to_rfc3339(); + self.save_canonical(&canonical)?; + Ok(canonical) + } + /// Get the canonical context window (optional summary + recent messages). + fn canonical_context( + &self, + agent_id: AgentId, + window_size: Option, + ) -> OpenFangResult<(Option, Vec)> { + let session = self.load_canonical(agent_id)?; + let window = window_size.unwrap_or(50); + let messages = if session.messages.len() > window { + session.messages[session.messages.len() - window..].to_vec() + } else { + session.messages + }; + Ok((session.compacted_summary, messages)) + } + /// Store an LLM-generated summary for the canonical session. + fn store_llm_summary( + &self, + agent_id: AgentId, + summary: &str, + kept_messages: Vec, + ) -> OpenFangResult<()> { + let mut canonical = self.load_canonical(agent_id)?; + canonical.compacted_summary = Some(summary.to_string()); + canonical.messages = kept_messages; + canonical.compaction_cursor = 0; + canonical.updated_at = Utc::now().to_rfc3339(); + self.save_canonical(&canonical) + } +} + +/// Backend for LLM usage tracking and cost metering. +pub trait UsageBackend: Send + Sync { + /// Record a usage event. + fn record(&self, record: &UsageRecord) -> OpenFangResult<()>; + /// Query total cost for an agent in the last hour. + fn query_hourly(&self, agent_id: AgentId) -> OpenFangResult; + /// Query total cost for an agent today. + fn query_daily(&self, agent_id: AgentId) -> OpenFangResult; + /// Query total cost for an agent this month. + fn query_monthly(&self, agent_id: AgentId) -> OpenFangResult; + /// Query total cost across all agents in the last hour. + fn query_global_hourly(&self) -> OpenFangResult; + /// Query total cost across all agents this month. + fn query_global_monthly(&self) -> OpenFangResult; + /// Get a summary of usage, optionally filtered by agent. + fn query_summary(&self, agent_id: Option) -> OpenFangResult; + /// Get usage breakdown by model. + fn query_by_model(&self) -> OpenFangResult>; + /// Get daily usage breakdown for the last N days. + fn query_daily_breakdown(&self, days: u32) -> OpenFangResult>; + /// Get the date of the first usage event. + fn query_first_event_date(&self) -> OpenFangResult>; + /// Get today's total cost. + fn query_today_cost(&self) -> OpenFangResult; + /// Delete usage events older than N days. Returns count deleted. + fn cleanup_old(&self, days: u32) -> OpenFangResult; +} + +/// Backend for paired device persistence. +pub trait PairedDevicesBackend: Send + Sync { + fn load_paired_devices(&self) -> OpenFangResult>; + fn save_paired_device( + &self, + device_id: &str, + display_name: &str, + platform: &str, + paired_at: &str, + last_seen: &str, + push_token: Option<&str>, + ) -> OpenFangResult<()>; + fn remove_paired_device(&self, device_id: &str) -> OpenFangResult<()>; +} + +/// Backend for the shared task queue. +pub trait TaskQueueBackend: Send + Sync { + fn task_post(&self, title: &str, description: &str, assigned_to: &str, created_by: &str) -> OpenFangResult; + fn task_claim(&self, agent_id: &str) -> OpenFangResult>; + fn task_complete(&self, task_id: &str, result: &str) -> OpenFangResult<()>; + fn task_list(&self, status: Option<&str>) -> OpenFangResult>; +} + +/// Backend for memory consolidation (confidence decay). +pub trait ConsolidationBackend: Send + Sync { + fn consolidate(&self) -> OpenFangResult; +} + +/// Backend for audit log persistence. +pub trait AuditBackend: Send + Sync { + fn append_entry(&self, agent_id: &str, action: &str, detail: &str, outcome: &str) -> OpenFangResult<()>; + fn load_entries(&self, agent_id: Option<&str>, limit: usize) -> OpenFangResult>; +} diff --git a/crates/openfang-memory/src/helpers.rs b/crates/openfang-memory/src/helpers.rs new file mode 100644 index 0000000000..540d442257 --- /dev/null +++ b/crates/openfang-memory/src/helpers.rs @@ -0,0 +1,211 @@ +//! Shared serialization, parsing, and construction helpers. +//! +//! These functions eliminate boilerplate duplicated across SQLite, PostgreSQL, +//! and Qdrant backend implementations. + +use openfang_types::agent::{AgentId, SessionId}; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::{Entity, EntityType, MemoryId, MemorySource, Relation, RelationType}; +use openfang_types::message::Message; +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// UUID parsing +// --------------------------------------------------------------------------- + +/// Parse a string into an [`AgentId`], returning a descriptive memory error. +pub fn parse_agent_id(s: &str) -> OpenFangResult { + uuid::Uuid::parse_str(s) + .map(AgentId) + .map_err(|e| OpenFangError::Memory(format!("invalid agent UUID: {e}"))) +} + +/// Parse a string into a [`SessionId`], returning a descriptive memory error. +pub fn parse_session_id(s: &str) -> OpenFangResult { + uuid::Uuid::parse_str(s) + .map(SessionId) + .map_err(|e| OpenFangError::Memory(format!("invalid session UUID: {e}"))) +} + +/// Parse a string into a [`MemoryId`], returning a descriptive memory error. +pub fn parse_memory_id(s: &str) -> OpenFangResult { + uuid::Uuid::parse_str(s) + .map(MemoryId) + .map_err(|e| OpenFangError::Memory(format!("invalid memory UUID: {e}"))) +} + +// --------------------------------------------------------------------------- +// Message msgpack ser/de +// --------------------------------------------------------------------------- + +/// Serialize a message slice to msgpack (compact encoding). +/// +/// Used by `save_canonical` and similar paths where field-name stability is +/// not required. +pub fn serialize_messages(messages: &[Message]) -> OpenFangResult> { + rmp_serde::to_vec(messages).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Serialize a message slice to msgpack with named fields. +/// +/// Preferred for session persistence so that new fields with `#[serde(default)]` +/// are handled gracefully across schema changes. +pub fn serialize_messages_named(messages: &[Message]) -> OpenFangResult> { + rmp_serde::to_vec_named(messages).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize messages from a msgpack blob, returning an empty vec on failure. +pub fn deserialize_messages_lossy(blob: &[u8]) -> Vec { + rmp_serde::from_slice(blob).unwrap_or_default() +} + +/// Deserialize messages from a msgpack blob, propagating errors. +pub fn deserialize_messages(blob: &[u8]) -> OpenFangResult> { + rmp_serde::from_slice(blob).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +// --------------------------------------------------------------------------- +// JSON ser/de for domain types +// --------------------------------------------------------------------------- + +/// Serialize a [`MemorySource`] to its JSON string representation. +pub fn serialize_source(source: &MemorySource) -> OpenFangResult { + serde_json::to_string(source).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize a [`MemorySource`] from JSON, falling back to [`MemorySource::System`]. +pub fn deserialize_source(s: &str) -> MemorySource { + serde_json::from_str(s).unwrap_or(MemorySource::System) +} + +/// Serialize an [`EntityType`] to its JSON string representation. +pub fn serialize_entity_type(et: &EntityType) -> OpenFangResult { + serde_json::to_string(et).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize an [`EntityType`] from JSON, falling back to `Custom("unknown")`. +pub fn deserialize_entity_type(s: &str) -> EntityType { + serde_json::from_str(s).unwrap_or(EntityType::Custom("unknown".to_string())) +} + +/// Serialize a [`RelationType`] to its JSON string representation. +pub fn serialize_relation_type(rt: &RelationType) -> OpenFangResult { + serde_json::to_string(rt).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize a [`RelationType`] from JSON, falling back to [`RelationType::RelatedTo`]. +pub fn deserialize_relation_type(s: &str) -> RelationType { + serde_json::from_str(s).unwrap_or(RelationType::RelatedTo) +} + +/// Serialize a properties map to a JSON string. +pub fn serialize_properties(props: &HashMap) -> OpenFangResult { + serde_json::to_string(props).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize a properties map from a JSON string, returning an empty map on failure. +pub fn deserialize_properties(s: &str) -> HashMap { + serde_json::from_str(s).unwrap_or_default() +} + +/// Serialize metadata to a JSON string (alias for [`serialize_properties`]). +pub fn serialize_metadata(meta: &HashMap) -> OpenFangResult { + serialize_properties(meta) +} + +/// Deserialize metadata from a JSON string (alias for [`deserialize_properties`]). +pub fn deserialize_metadata(s: &str) -> HashMap { + deserialize_properties(s) +} + +// --------------------------------------------------------------------------- +// Agent manifest msgpack +// --------------------------------------------------------------------------- + +/// Serialize an [`AgentManifest`] to msgpack with named fields. +/// +/// Named-field encoding ensures new fields with `#[serde(default)]` are +/// handled gracefully when the struct evolves between versions. +pub fn serialize_manifest( + manifest: &openfang_types::agent::AgentManifest, +) -> OpenFangResult> { + rmp_serde::to_vec_named(manifest).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +/// Deserialize an [`AgentManifest`] from a msgpack blob. +pub fn deserialize_manifest( + blob: &[u8], +) -> OpenFangResult { + rmp_serde::from_slice(blob).map_err(|e| OpenFangError::Serialization(e.to_string())) +} + +// --------------------------------------------------------------------------- +// Timestamp helpers +// --------------------------------------------------------------------------- + +/// Parse an RFC 3339 timestamp, falling back to `Utc::now()` on failure. +pub fn parse_rfc3339_or_now(s: &str) -> chrono::DateTime { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()) +} + +// --------------------------------------------------------------------------- +// Knowledge graph builders +// --------------------------------------------------------------------------- + +/// Build an [`Entity`] from raw database column values. +pub fn build_entity( + id: &str, + etype_str: &str, + name: &str, + props_str: &str, + created_str: &str, + updated_str: &str, +) -> Entity { + Entity { + id: id.to_string(), + entity_type: deserialize_entity_type(etype_str), + name: name.to_string(), + properties: deserialize_properties(props_str), + created_at: parse_rfc3339_or_now(created_str), + updated_at: parse_rfc3339_or_now(updated_str), + } +} + +/// Build a [`Relation`] from raw database column values. +pub fn build_relation( + source: &str, + rtype_str: &str, + target: &str, + props_str: &str, + confidence: f64, + created_str: &str, +) -> Relation { + Relation { + source: source.to_string(), + relation: deserialize_relation_type(rtype_str), + target: target.to_string(), + properties: deserialize_properties(props_str), + confidence: confidence as f32, + created_at: parse_rfc3339_or_now(created_str), + } +} + +// --------------------------------------------------------------------------- +// ID generation +// --------------------------------------------------------------------------- + +/// Return `existing` if non-empty, otherwise generate a new v4 UUID string. +pub fn entity_id_or_generate(existing: &str) -> String { + if existing.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + existing.to_string() + } +} + +/// Generate a new v4 UUID string for a relation row. +pub fn new_relation_id() -> String { + uuid::Uuid::new_v4().to_string() +} diff --git a/crates/openfang-memory/src/http/mod.rs b/crates/openfang-memory/src/http/mod.rs new file mode 100644 index 0000000000..23ae162700 --- /dev/null +++ b/crates/openfang-memory/src/http/mod.rs @@ -0,0 +1,6 @@ +//! HTTP backend for the OpenFang memory layer. +//! +//! Routes semantic operations (remember/recall) to a remote memory-api gateway. + +mod semantic; +pub use semantic::{HttpSemanticStore, MemoryApiClient, MemoryApiError}; diff --git a/crates/openfang-memory/src/http/semantic.rs b/crates/openfang-memory/src/http/semantic.rs new file mode 100644 index 0000000000..f944452315 --- /dev/null +++ b/crates/openfang-memory/src/http/semantic.rs @@ -0,0 +1,451 @@ +//! HTTP client for the memory-api gateway. +//! +//! Provides a blocking HTTP client that routes `remember` and `recall` operations +//! to the shared memory-api service (PostgreSQL + pgvector + Jina AI embeddings). +//! Designed to be called from synchronous SemanticStore methods within +//! `spawn_blocking` contexts. + +use chrono::Utc; +use openfang_types::agent::AgentId; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySource}; +use openfang_types::storage::SemanticBackend; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{debug, warn}; + +/// Error type for memory API operations. +#[derive(Debug, thiserror::Error)] +pub enum MemoryApiError { + #[error("HTTP error: {0}")] + Http(String), + #[error("API error (status {status}): {message}")] + Api { status: u16, message: String }, + #[error("Parse error: {0}")] + Parse(String), + #[error("Missing config: {0}")] + Config(String), +} + +/// HTTP client for the memory-api gateway service. +#[derive(Clone)] +pub struct MemoryApiClient { + base_url: String, + token: String, + client: reqwest::blocking::Client, +} + +// -- Request/Response types matching memory-api endpoints -- + +#[derive(Serialize)] +struct StoreRequest<'a> { + content: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + category: Option<&'a str>, + #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")] + agent_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + importance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option>, +} + +#[derive(Deserialize, Debug)] +pub struct StoreResponse { + pub id: serde_json::Value, + #[serde(default)] + pub deduplicated: bool, +} + +/// Parse a `serde_json::Value` returned by the memory-api into a `MemoryId`. +/// The server is expected to emit IDs as UUID strings; any other shape is a +/// protocol mismatch and is surfaced as an error rather than fabricating an ID. +fn parse_memory_id(v: &serde_json::Value) -> OpenFangResult { + let s = v.as_str().ok_or_else(|| { + OpenFangError::Memory(format!( + "memory-api returned non-string id; expected UUID string, got: {v}" + )) + })?; + let uuid = uuid::Uuid::parse_str(s).map_err(|e| { + OpenFangError::Memory(format!("memory-api returned invalid UUID id {s:?}: {e}")) + })?; + Ok(MemoryId(uuid)) +} + +#[derive(Serialize)] +struct SearchRequest<'a> { + query: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + category: Option<&'a str>, +} + +#[derive(Deserialize, Debug)] +pub struct SearchResponse { + pub results: Vec, + pub count: usize, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SearchResult { + pub id: serde_json::Value, + pub content: String, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub score: f64, + #[serde(rename = "createdAt", default)] + pub created_at: Option, +} + +#[derive(Deserialize, Debug)] +struct HealthResponse { + pub status: String, +} + +impl MemoryApiClient { + /// Create a new memory-api HTTP client. + /// + /// `base_url`: The base URL of the memory-api service (e.g., "http://127.0.0.1:5500"). + /// `token_env`: The name of the environment variable holding the bearer token. + pub fn new(base_url: &str, token_env: &str) -> Result { + let token = if token_env.is_empty() { + String::new() + } else { + std::env::var(token_env).unwrap_or_else(|_| { + warn!(env = token_env, "Memory API token env var not set"); + String::new() + }) + }; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .user_agent("openfang-memory/0.4") + .build() + .map_err(|e| MemoryApiError::Http(e.to_string()))?; + + let base_url = base_url.trim_end_matches('/').to_string(); + + Ok(Self { + base_url, + token, + client, + }) + } + + /// Check if memory-api is reachable. + pub fn health_check(&self) -> Result<(), MemoryApiError> { + let url = format!("{}/health", self.base_url); + let resp = self + .client + .get(&url) + .send() + .map_err(|e| MemoryApiError::Http(e.to_string()))?; + + if !resp.status().is_success() { + return Err(MemoryApiError::Api { + status: resp.status().as_u16(), + message: resp.text().unwrap_or_default(), + }); + } + + let body: HealthResponse = resp + .json() + .map_err(|e| MemoryApiError::Parse(e.to_string()))?; + + if body.status != "ok" { + return Err(MemoryApiError::Api { + status: 503, + message: format!("memory-api status: {}", body.status), + }); + } + + debug!("memory-api health check passed"); + Ok(()) + } + + /// Store a memory via POST /memory/store. + /// + /// The memory-api handles embedding generation (Jina AI) and deduplication. + pub fn store( + &self, + content: &str, + category: Option<&str>, + agent_id: Option<&str>, + source: Option<&str>, + importance: Option, + tags: Option>, + ) -> Result { + let url = format!("{}/memory/store", self.base_url); + + let body = StoreRequest { + content, + category, + agent_id, + source, + importance, + tags, + }; + + let mut req = self.client.post(&url).json(&body); + if !self.token.is_empty() { + req = req.header("Authorization", format!("Bearer {}", self.token)); + } + + let resp = req + .send() + .map_err(|e| MemoryApiError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + + if status != 200 && status != 201 { + let body_text = resp.text().unwrap_or_default(); + return Err(MemoryApiError::Api { + status, + message: body_text, + }); + } + + let result: StoreResponse = resp + .json() + .map_err(|e| MemoryApiError::Parse(e.to_string()))?; + + debug!( + id = %result.id, + deduplicated = result.deduplicated, + "Stored memory via HTTP" + ); + + Ok(result) + } + + /// Search memories via POST /memory/search. + /// + /// The memory-api handles embedding the query (Jina AI) and hybrid vector+BM25 search. + pub fn search( + &self, + query: &str, + limit: usize, + category: Option<&str>, + ) -> Result, MemoryApiError> { + let url = format!("{}/memory/search", self.base_url); + + let body = SearchRequest { + query, + limit: Some(limit), + category, + }; + + let mut req = self.client.post(&url).json(&body); + if !self.token.is_empty() { + req = req.header("Authorization", format!("Bearer {}", self.token)); + } + + let resp = req + .send() + .map_err(|e| MemoryApiError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + + if status != 200 { + let body_text = resp.text().unwrap_or_default(); + return Err(MemoryApiError::Api { + status, + message: body_text, + }); + } + + let result: SearchResponse = resp + .json() + .map_err(|e| MemoryApiError::Parse(e.to_string()))?; + + debug!(count = result.count, "Searched memories via HTTP"); + + Ok(result.results) + } +} + +/// HTTP semantic backend that routes remember/recall to a remote memory-api gateway. +/// Falls back to a local backend for operations the API doesn't support (forget, +/// update_embedding) and on HTTP errors. +pub struct HttpSemanticStore { + client: MemoryApiClient, + fallback: Arc, +} + +impl HttpSemanticStore { + pub fn new(client: MemoryApiClient, fallback: Arc) -> Self { + Self { client, fallback } + } +} + +impl SemanticBackend for HttpSemanticStore { + fn remember( + &self, + agent_id: AgentId, + content: &str, + source: MemorySource, + scope: &str, + metadata: HashMap, + embedding: Option<&[f32]>, + ) -> OpenFangResult { + let source_str = format!("{:?}", source).to_lowercase(); + let importance = metadata + .get("importance") + .and_then(|v| v.as_u64()) + .map(|v| v.min(10) as u8) + .unwrap_or(5); + let tags: Option> = metadata + .get("tags") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + match self.client.store( + content, + Some(scope), + Some(&agent_id.0.to_string()), + Some(&source_str), + Some(importance), + tags, + ) { + Ok(resp) => match parse_memory_id(&resp.id) { + Ok(id) => { + debug!(id = %id, "Stored memory via HTTP backend"); + Ok(id) + } + Err(e) => { + warn!(error = %e, "memory-api returned malformed id, falling back to local"); + self.fallback + .remember(agent_id, content, source, scope, metadata, embedding) + } + }, + Err(e) => { + warn!(error = %e, "HTTP memory store failed, falling back to local"); + self.fallback + .remember(agent_id, content, source, scope, metadata, embedding) + } + } + } + + fn recall( + &self, + query: &str, + limit: usize, + filter: Option, + query_embedding: Option<&[f32]>, + ) -> OpenFangResult> { + let category: Option = filter.as_ref().and_then(|f| f.scope.clone()); + + match self + .client + .search(query, limit, category.as_deref()) + .map_err(|e| OpenFangError::Memory(format!("HTTP search failed: {e}"))) + { + Ok(results) => { + let mut fragments: Vec = Vec::with_capacity(results.len()); + for r in results { + let id = match parse_memory_id(&r.id) { + Ok(id) => id, + Err(e) => { + warn!(error = %e, "dropping memory-api result with malformed id"); + continue; + } + }; + let created_at = r + .created_at + .map(|ms| { + chrono::DateTime::from_timestamp_millis(ms as i64) + .unwrap_or_else(Utc::now) + }) + .unwrap_or_else(Utc::now); + + fragments.push(MemoryFragment { + id, + agent_id: filter + .as_ref() + .and_then(|f| f.agent_id) + .unwrap_or_default(), + content: r.content, + embedding: None, + metadata: HashMap::new(), + source: MemorySource::System, + confidence: r.score as f32, + created_at, + accessed_at: Utc::now(), + access_count: 0, + scope: r.category.unwrap_or_else(|| "general".to_string()), + }); + } + + debug!( + count = fragments.len(), + "Recalled memories via HTTP backend" + ); + Ok(fragments) + } + Err(e) => { + warn!(error = %e, "HTTP memory search failed, falling back to local"); + self.fallback.recall(query, limit, filter, query_embedding) + } + } + } + + fn forget(&self, _id: MemoryId) -> OpenFangResult<()> { + // The HTTP memory-api does not expose a delete endpoint, and the local + // fallback never saw the row (it was written remotely), so delegating + // there would silently no-op against the wrong store. + Err(OpenFangError::Memory( + "HTTP semantic backend does not support forget(); \ + configure a local semantic_backend to use this operation" + .into(), + )) + } + + fn update_embedding(&self, _id: MemoryId, _embedding: &[f32]) -> OpenFangResult<()> { + // memory-api owns embedding generation server-side; there is no client + // path to override it, and the local fallback does not hold this row. + Err(OpenFangError::Memory( + "HTTP semantic backend does not support update_embedding(); \ + the remote service manages embeddings" + .into(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_memory_id_accepts_valid_uuid_string() { + let raw = "550e8400-e29b-41d4-a716-446655440000"; + let id = parse_memory_id(&serde_json::json!(raw)).expect("valid uuid parses"); + assert_eq!(id.0, uuid::Uuid::parse_str(raw).unwrap()); + } + + #[test] + fn parse_memory_id_rejects_non_string_value() { + let err = parse_memory_id(&serde_json::json!(42)).expect_err("non-string must error"); + match err { + OpenFangError::Memory(msg) => { + assert!(msg.contains("memory-api"), "msg={msg}"); + assert!(msg.contains("non-string"), "msg={msg}"); + } + other => panic!("expected OpenFangError::Memory, got {other:?}"), + } + } + + #[test] + fn parse_memory_id_rejects_invalid_uuid_string() { + let err = + parse_memory_id(&serde_json::json!("not-a-uuid")).expect_err("bad uuid must error"); + match err { + OpenFangError::Memory(msg) => { + assert!(msg.contains("memory-api"), "msg={msg}"); + assert!(msg.contains("invalid UUID"), "msg={msg}"); + } + other => panic!("expected OpenFangError::Memory, got {other:?}"), + } + } +} diff --git a/crates/openfang-memory/src/http_client.rs b/crates/openfang-memory/src/http_client.rs deleted file mode 100644 index 1bdf13dd5a..0000000000 --- a/crates/openfang-memory/src/http_client.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! HTTP client for the memory-api gateway. -//! -//! Provides a blocking HTTP client that routes `remember` and `recall` operations -//! to the shared memory-api service (PostgreSQL + pgvector + Jina AI embeddings). -//! Designed to be called from synchronous SemanticStore methods within -//! `spawn_blocking` contexts. - -use serde::{Deserialize, Serialize}; -use tracing::{debug, warn}; - -/// Error type for memory API operations. -#[derive(Debug, thiserror::Error)] -pub enum MemoryApiError { - #[error("HTTP error: {0}")] - Http(String), - #[error("API error (status {status}): {message}")] - Api { status: u16, message: String }, - #[error("Parse error: {0}")] - Parse(String), - #[error("Missing config: {0}")] - Config(String), -} - -/// HTTP client for the memory-api gateway service. -#[derive(Clone)] -pub struct MemoryApiClient { - base_url: String, - token: String, - client: reqwest::blocking::Client, -} - -// -- Request/Response types matching memory-api endpoints -- - -#[derive(Serialize)] -struct StoreRequest<'a> { - content: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - category: Option<&'a str>, - #[serde(rename = "agentId", skip_serializing_if = "Option::is_none")] - agent_id: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - source: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - importance: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tags: Option>, -} - -#[derive(Deserialize, Debug)] -pub struct StoreResponse { - pub id: serde_json::Value, - #[serde(default)] - pub deduplicated: bool, -} - -#[derive(Serialize)] -struct SearchRequest<'a> { - query: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - category: Option<&'a str>, -} - -#[derive(Deserialize, Debug)] -pub struct SearchResponse { - pub results: Vec, - pub count: usize, -} - -#[derive(Deserialize, Debug, Clone)] -pub struct SearchResult { - pub id: serde_json::Value, - pub content: String, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub score: f64, - #[serde(rename = "createdAt", default)] - pub created_at: Option, -} - -#[derive(Deserialize, Debug)] -struct HealthResponse { - pub status: String, -} - -impl MemoryApiClient { - /// Create a new memory-api HTTP client. - /// - /// `base_url`: The base URL of the memory-api service (e.g., "http://127.0.0.1:5500"). - /// `token_env`: The name of the environment variable holding the bearer token. - pub fn new(base_url: &str, token_env: &str) -> Result { - let token = if token_env.is_empty() { - String::new() - } else { - std::env::var(token_env).unwrap_or_else(|_| { - warn!(env = token_env, "Memory API token env var not set"); - String::new() - }) - }; - - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .user_agent("openfang-memory/0.4") - .build() - .map_err(|e| MemoryApiError::Http(e.to_string()))?; - - let base_url = base_url.trim_end_matches('/').to_string(); - - Ok(Self { - base_url, - token, - client, - }) - } - - /// Check if memory-api is reachable. - pub fn health_check(&self) -> Result<(), MemoryApiError> { - let url = format!("{}/health", self.base_url); - let resp = self - .client - .get(&url) - .send() - .map_err(|e| MemoryApiError::Http(e.to_string()))?; - - if !resp.status().is_success() { - return Err(MemoryApiError::Api { - status: resp.status().as_u16(), - message: resp.text().unwrap_or_default(), - }); - } - - let body: HealthResponse = resp - .json() - .map_err(|e| MemoryApiError::Parse(e.to_string()))?; - - if body.status != "ok" { - return Err(MemoryApiError::Api { - status: 503, - message: format!("memory-api status: {}", body.status), - }); - } - - debug!("memory-api health check passed"); - Ok(()) - } - - /// Store a memory via POST /memory/store. - /// - /// The memory-api handles embedding generation (Jina AI) and deduplication. - pub fn store( - &self, - content: &str, - category: Option<&str>, - agent_id: Option<&str>, - source: Option<&str>, - importance: Option, - tags: Option>, - ) -> Result { - let url = format!("{}/memory/store", self.base_url); - - let body = StoreRequest { - content, - category, - agent_id, - source, - importance, - tags, - }; - - let mut req = self.client.post(&url).json(&body); - if !self.token.is_empty() { - req = req.header("Authorization", format!("Bearer {}", self.token)); - } - - let resp = req - .send() - .map_err(|e| MemoryApiError::Http(e.to_string()))?; - let status = resp.status().as_u16(); - - if status != 200 && status != 201 { - let body_text = resp.text().unwrap_or_default(); - return Err(MemoryApiError::Api { - status, - message: body_text, - }); - } - - let result: StoreResponse = resp - .json() - .map_err(|e| MemoryApiError::Parse(e.to_string()))?; - - debug!( - id = %result.id, - deduplicated = result.deduplicated, - "Stored memory via HTTP" - ); - - Ok(result) - } - - /// Search memories via POST /memory/search. - /// - /// The memory-api handles embedding the query (Jina AI) and hybrid vector+BM25 search. - pub fn search( - &self, - query: &str, - limit: usize, - category: Option<&str>, - ) -> Result, MemoryApiError> { - let url = format!("{}/memory/search", self.base_url); - - let body = SearchRequest { - query, - limit: Some(limit), - category, - }; - - let mut req = self.client.post(&url).json(&body); - if !self.token.is_empty() { - req = req.header("Authorization", format!("Bearer {}", self.token)); - } - - let resp = req - .send() - .map_err(|e| MemoryApiError::Http(e.to_string()))?; - let status = resp.status().as_u16(); - - if status != 200 { - let body_text = resp.text().unwrap_or_default(); - return Err(MemoryApiError::Api { - status, - message: body_text, - }); - } - - let result: SearchResponse = resp - .json() - .map_err(|e| MemoryApiError::Parse(e.to_string()))?; - - debug!(count = result.count, "Searched memories via HTTP"); - - Ok(result.results) - } -} diff --git a/crates/openfang-memory/src/jsonl.rs b/crates/openfang-memory/src/jsonl.rs new file mode 100644 index 0000000000..26fe621600 --- /dev/null +++ b/crates/openfang-memory/src/jsonl.rs @@ -0,0 +1,105 @@ +//! JSONL session mirror writer. +//! Pure filesystem utility — no database dependency. + +use crate::session::Session; +use chrono::Utc; +use openfang_types::message::{ContentBlock, MessageContent, Role}; +use std::io::Write; +use std::path::Path; + +/// A single JSONL line in the session mirror file. +#[derive(serde::Serialize)] +struct JsonlLine { + timestamp: String, + role: String, + content: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + tool_use: Option, +} + +/// Write a human-readable JSONL mirror of a session to disk. +/// +/// Best-effort: errors are returned but should be logged and never +/// affect the primary SQLite store. +pub fn write_session_mirror(session: &Session, sessions_dir: &Path) -> Result<(), std::io::Error> { + std::fs::create_dir_all(sessions_dir)?; + // SessionId wraps a uuid::Uuid (see openfang_types::agent::SessionId), so the filename is path-traversal safe. + let path = sessions_dir.join(format!("{}.jsonl", session.id.0)); + let mut file = std::fs::File::create(&path)?; + let now = Utc::now().to_rfc3339(); + + for msg in &session.messages { + let role_str = match msg.role { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + }; + + let mut text_parts: Vec = Vec::new(); + let mut tool_parts: Vec = Vec::new(); + + match &msg.content { + MessageContent::Text(t) => { + text_parts.push(t.clone()); + } + MessageContent::Blocks(blocks) => { + for block in blocks { + match block { + ContentBlock::Text { text, .. } => { + text_parts.push(text.clone()); + } + ContentBlock::ToolUse { + id, name, input, .. + } => { + tool_parts.push(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + })); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name: _, + content, + is_error, + } => { + tool_parts.push(serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + "is_error": is_error, + })); + } + ContentBlock::Image { media_type, .. } => { + text_parts.push(format!("[image: {media_type}]")); + } + ContentBlock::Thinking { thinking, .. } => { + text_parts.push(format!( + "[thinking: {}]", + openfang_types::truncate_str(thinking, 200) + )); + } + ContentBlock::Unknown => {} + } + } + } + } + + let line = JsonlLine { + timestamp: now.clone(), + role: role_str.to_string(), + content: serde_json::Value::String(text_parts.join("\n")), + tool_use: if tool_parts.is_empty() { + None + } else { + Some(serde_json::Value::Array(tool_parts)) + }, + }; + + serde_json::to_writer(&mut file, &line).map_err(std::io::Error::other)?; + file.write_all(b"\n")?; + } + + Ok(()) +} diff --git a/crates/openfang-memory/src/lib.rs b/crates/openfang-memory/src/lib.rs index bf16737a02..7b1f82538d 100644 --- a/crates/openfang-memory/src/lib.rs +++ b/crates/openfang-memory/src/lib.rs @@ -7,15 +7,19 @@ //! //! Agents interact with a single `Memory` trait that abstracts over all three stores. -pub mod consolidation; +pub mod backends; +pub mod helpers; #[cfg(feature = "http-memory")] -pub mod http_client; -pub mod knowledge; -pub mod migration; -pub mod semantic; +pub mod http; +pub mod jsonl; +#[cfg(feature = "postgres")] +pub mod postgres; +#[cfg(feature = "qdrant")] +pub mod qdrant; pub mod session; -pub mod structured; +pub mod sqlite; pub mod usage; mod substrate; +pub use openfang_types::config::{MemoryBackendKind, SemanticBackendKind}; pub use substrate::MemorySubstrate; diff --git a/crates/openfang-memory/src/postgres/audit.rs b/crates/openfang-memory/src/postgres/audit.rs new file mode 100644 index 0000000000..18ec16c25c --- /dev/null +++ b/crates/openfang-memory/src/postgres/audit.rs @@ -0,0 +1,162 @@ +//! PostgreSQL implementation of the audit log store. +//! +//! Uses a SHA-256 Merkle hash chain matching the scheme in `openfang-runtime`'s +//! `AuditLog`, so entries written through this backend are chain-compatible with +//! entries written directly by the runtime. + +use crate::backends::AuditBackend; +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use sha2::{Digest, Sha256}; + +pub struct PgAuditStore { + pool: Pool, +} + +impl PgAuditStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f)) + } +} + +/// Compute the SHA-256 hash for a single audit entry, matching the scheme in +/// `openfang-runtime::audit::compute_entry_hash`. +fn compute_entry_hash( + seq: i64, + timestamp: &str, + agent_id: &str, + action: &str, + detail: &str, + outcome: &str, + prev_hash: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(seq.to_string().as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(agent_id.as_bytes()); + hasher.update(action.as_bytes()); + hasher.update(detail.as_bytes()); + hasher.update(outcome.as_bytes()); + hasher.update(prev_hash.as_bytes()); + hex::encode(hasher.finalize()) +} + +impl AuditBackend for PgAuditStore { + fn append_entry( + &self, + agent_id: &str, + action: &str, + detail: &str, + outcome: &str, + ) -> OpenFangResult<()> { + let agent_id = agent_id.to_string(); + let action = action.to_string(); + let detail = detail.to_string(); + let outcome = outcome.to_string(); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let now = chrono::Utc::now().to_rfc3339(); + + // Get the previous hash for chain continuity. + let prev_row = client + .query_opt( + "SELECT hash FROM audit_entries ORDER BY seq DESC LIMIT 1", + &[], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let prev_hash = prev_row + .map(|r| r.get::<_, String>(0)) + .unwrap_or_else(|| "0".repeat(64)); + + // Insert using BIGSERIAL seq (returned by RETURNING). + let row = client + .query_one( + "INSERT INTO audit_entries (timestamp, agent_id, action, detail, outcome, prev_hash, hash) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING seq", + &[&now, &agent_id, &action, &detail, &outcome, &prev_hash, &String::new()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let seq: i64 = row.get(0); + + // Now compute the hash with the real seq and update. + let hash = compute_entry_hash(seq, &now, &agent_id, &action, &detail, &outcome, &prev_hash); + client + .execute( + "UPDATE audit_entries SET hash = $2 WHERE seq = $1", + &[&seq, &hash], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + Ok(()) + }) + } + + fn load_entries( + &self, + agent_id: Option<&str>, + limit: usize, + ) -> OpenFangResult> { + let agent_id = agent_id.map(|s| s.to_string()); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let limit_i64 = limit as i64; + let rows = match &agent_id { + Some(aid) => { + client + .query( + "SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash + FROM audit_entries WHERE agent_id = $1 ORDER BY seq DESC LIMIT $2", + &[aid, &limit_i64], + ) + .await + } + None => { + client + .query( + "SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash + FROM audit_entries ORDER BY seq DESC LIMIT $1", + &[&limit_i64], + ) + .await + } + } + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + serde_json::json!({ + "seq": r.get::<_, i64>(0), + "timestamp": r.get::<_, String>(1), + "agent_id": r.get::<_, String>(2), + "action": r.get::<_, String>(3), + "detail": r.get::<_, String>(4), + "outcome": r.get::<_, String>(5), + "prev_hash": r.get::<_, String>(6), + "hash": r.get::<_, String>(7), + }) + }) + .collect()) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/consolidation.rs b/crates/openfang-memory/src/postgres/consolidation.rs new file mode 100644 index 0000000000..6168fdfe6f --- /dev/null +++ b/crates/openfang-memory/src/postgres/consolidation.rs @@ -0,0 +1,66 @@ +//! PostgreSQL implementation of memory consolidation (confidence decay). + +use crate::backends::ConsolidationBackend; +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::ConsolidationReport; + +pub struct PgConsolidationEngine { + pool: Pool, + /// Decay rate: how much to reduce confidence per consolidation cycle. + decay_rate: f32, +} + +impl PgConsolidationEngine { + pub fn new(pool: Pool) -> Self { + Self { + pool, + decay_rate: 0.1, + } + } + + pub fn with_decay_rate(mut self, rate: f32) -> Self { + self.decay_rate = rate; + self + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f)) + } +} + +impl ConsolidationBackend for PgConsolidationEngine { + fn consolidate(&self) -> OpenFangResult { + let start = std::time::Instant::now(); + let decay_factor: f32 = 1.0 - self.decay_rate; + let cutoff = chrono::Utc::now() - chrono::Duration::days(7); + + let decayed = self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .execute( + "UPDATE memories SET confidence = GREATEST(0.1::real, confidence * $1) + WHERE deleted = FALSE AND accessed_at < $2 AND confidence > 0.1", + &[&decay_factor, &cutoff], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows) + })?; + + let duration_ms = start.elapsed().as_millis() as u64; + + Ok(ConsolidationReport { + memories_merged: 0, + memories_decayed: decayed, + duration_ms, + }) + } +} diff --git a/crates/openfang-memory/src/postgres/knowledge.rs b/crates/openfang-memory/src/postgres/knowledge.rs new file mode 100644 index 0000000000..eeb06669bb --- /dev/null +++ b/crates/openfang-memory/src/postgres/knowledge.rs @@ -0,0 +1,153 @@ +//! PostgreSQL implementation of the knowledge graph store. + +use crate::helpers; +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::{Entity, GraphMatch, GraphPattern, Relation}; +use openfang_types::storage::KnowledgeBackend; + +pub struct PgKnowledgeStore { + pool: Pool, +} + +impl PgKnowledgeStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) + } +} + +impl KnowledgeBackend for PgKnowledgeStore { + fn add_entity(&self, entity: Entity) -> OpenFangResult { + let id = helpers::entity_id_or_generate(&entity.id); + let entity_type_str = helpers::serialize_entity_type(&entity.entity_type)?; + let props_json = serde_json::to_value(&entity.properties) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO entities (id, entity_type, name, properties, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET name = $3, properties = $4, updated_at = NOW()", + &[&id, &entity_type_str, &entity.name, &props_json], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(id) + }) + } + + fn add_relation(&self, relation: Relation) -> OpenFangResult { + let id = helpers::new_relation_id(); + let rel_type_str = helpers::serialize_relation_type(&relation.relation)?; + let props_json = serde_json::to_value(&relation.properties) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO relations (id, source_entity, relation_type, target_entity, properties, confidence, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW())", + &[&id, &relation.source, &rel_type_str, &relation.target, &props_json, &relation.confidence], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(id) + }) + } + + fn query_graph(&self, pattern: GraphPattern) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut conditions = vec!["TRUE".to_string()]; + let mut params: Vec> = Vec::new(); + let mut idx = 1u32; + + if let Some(ref source) = pattern.source { + conditions.push(format!("(s.id = ${idx} OR s.name = ${idx})")); + params.push(Box::new(source.clone())); + idx += 1; + } + if let Some(ref relation) = pattern.relation { + let rel_str = helpers::serialize_relation_type(relation)?; + conditions.push(format!("r.relation_type = ${idx}")); + params.push(Box::new(rel_str)); + idx += 1; + } + if let Some(ref target) = pattern.target { + conditions.push(format!("(t.id = ${idx} OR t.name = ${idx})")); + params.push(Box::new(target.clone())); + idx += 1; + } + let _ = idx; + + let where_clause = conditions.join(" AND "); + let sql = format!( + "SELECT s.id, s.entity_type, s.name, s.properties, s.created_at, s.updated_at, + r.source_entity, r.relation_type, r.target_entity, r.properties, r.confidence, r.created_at, + t.id, t.entity_type, t.name, t.properties, t.created_at, t.updated_at + FROM relations r + JOIN entities s ON r.source_entity = s.id + JOIN entities t ON r.target_entity = t.id + WHERE {where_clause} + LIMIT 100" + ); + + let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = + params.iter().map(|b| b.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)).collect(); + let rows = client.query(&sql, ¶m_refs).await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut matches = Vec::new(); + for row in &rows { + let parse_entity = |id_idx: usize| -> Entity { + let id: String = row.get(id_idx); + let etype_str: String = row.get(id_idx + 1); + let name: String = row.get(id_idx + 2); + let props_json: serde_json::Value = row.get(id_idx + 3); + let created_at: chrono::DateTime = row.get(id_idx + 4); + let updated_at: chrono::DateTime = row.get(id_idx + 5); + let props_str = props_json.to_string(); + helpers::build_entity( + &id, &etype_str, &name, &props_str, + &created_at.to_rfc3339(), &updated_at.to_rfc3339(), + ) + }; + + let source = parse_entity(0); + let target = parse_entity(12); + + let r_source: String = row.get(6); + let r_type_str: String = row.get(7); + let r_target: String = row.get(8); + let r_props_json: serde_json::Value = row.get(9); + let r_confidence: f32 = row.get(10); + let r_created: chrono::DateTime = row.get(11); + let r_props_str = r_props_json.to_string(); + + let relation = helpers::build_relation( + &r_source, &r_type_str, &r_target, &r_props_str, + r_confidence as f64, &r_created.to_rfc3339(), + ); + + matches.push(GraphMatch { source, relation, target }); + } + Ok(matches) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/migration.rs b/crates/openfang-memory/src/postgres/migration.rs new file mode 100644 index 0000000000..784b3bf5bd --- /dev/null +++ b/crates/openfang-memory/src/postgres/migration.rs @@ -0,0 +1,401 @@ +//! PostgreSQL schema creation and migration. +//! +//! Mirrors the SQLite migration structure (v1-v9) with PostgreSQL types: +//! - `BLOB` → `BYTEA` +//! - `TEXT` timestamps → `TIMESTAMPTZ` +//! - `INTEGER` booleans → `BOOLEAN` +//! - `TEXT` JSON properties → `JSONB` +//! - `BLOB` embeddings → `vector` (pgvector) +//! - Auto-increment → `BIGSERIAL` + +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; + +/// Current schema version. +const SCHEMA_VERSION: i32 = 9; + +/// Run all migrations to bring the database up to date. +pub async fn run_migrations(pool: &Pool) -> OpenFangResult<()> { + let client = pool + .get() + .await + .map_err(|e| OpenFangError::Memory(format!("Failed to get PG connection: {e}")))?; + + // Enable pgvector extension + client + .execute("CREATE EXTENSION IF NOT EXISTS vector", &[]) + .await + .map_err(|e| OpenFangError::Memory(format!("Failed to enable pgvector: {e}")))?; + + // Ensure schema_version tracking table exists + client + .execute( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)", + &[], + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration failed: {e}")))?; + + // Initialize version row if empty + let count: i64 = client + .query_one("SELECT COUNT(*) FROM schema_version", &[]) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))? + .get(0); + if count == 0 { + client + .execute("INSERT INTO schema_version (version) VALUES (0)", &[]) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + } + + let current_version: i32 = client + .query_one("SELECT version FROM schema_version LIMIT 1", &[]) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))? + .get(0); + + if current_version < 1 { + migrate_v1(&client).await?; + } + if current_version < 2 { + migrate_v2(&client).await?; + } + if current_version < 3 { + migrate_v3(&client).await?; + } + if current_version < 4 { + migrate_v4(&client).await?; + } + if current_version < 5 { + migrate_v5(&client).await?; + } + if current_version < 6 { + migrate_v6(&client).await?; + } + if current_version < 7 { + migrate_v7(&client).await?; + } + if current_version < 8 { + migrate_v8(&client).await?; + } + if current_version < 9 { + migrate_v9(&client).await?; + } + + // Update version + client + .execute( + "UPDATE schema_version SET version = $1", + &[&SCHEMA_VERSION], + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration failed: {e}")))?; + + Ok(()) +} + +type PgClient = deadpool_postgres::Object; + +/// Version 1: Create all core tables. +async fn migrate_v1(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + -- Agent registry + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + manifest BYTEA NOT NULL, + state TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Session history + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + messages BYTEA NOT NULL, + context_window_tokens BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Event log + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + source_agent TEXT NOT NULL, + target TEXT NOT NULL, + payload BYTEA NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp); + CREATE INDEX IF NOT EXISTS idx_events_source ON events(source_agent); + + -- Key-value store (per-agent) + CREATE TABLE IF NOT EXISTS kv_store ( + agent_id TEXT NOT NULL, + key TEXT NOT NULL, + value BYTEA NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (agent_id, key) + ); + + -- Task queue + CREATE TABLE IF NOT EXISTS task_queue ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + task_type TEXT NOT NULL, + payload BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + priority INTEGER NOT NULL DEFAULT 0, + scheduled_at TEXT, + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_task_status_priority ON task_queue(status, priority DESC); + + -- Semantic memories + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'episodic', + confidence REAL NOT NULL DEFAULT 1.0, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + accessed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + access_count BIGINT NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id); + CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope); + + -- Knowledge graph entities + CREATE TABLE IF NOT EXISTS entities ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + name TEXT NOT NULL, + properties JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Knowledge graph relations + CREATE TABLE IF NOT EXISTS relations ( + id TEXT PRIMARY KEY, + source_entity TEXT NOT NULL REFERENCES entities(id), + relation_type TEXT NOT NULL, + target_entity TEXT NOT NULL REFERENCES entities(id), + properties JSONB NOT NULL DEFAULT '{}', + confidence REAL NOT NULL DEFAULT 1.0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_entity); + CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_entity); + CREATE INDEX IF NOT EXISTS idx_relations_type ON relations(relation_type); + + -- Migration tracking + CREATE TABLE IF NOT EXISTS migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + description TEXT + ); + + INSERT INTO migrations (version, applied_at, description) + VALUES (1, NOW(), 'Initial schema') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v1 failed: {e}")))?; + Ok(()) +} + +/// Version 2: Add collaboration columns to task_queue. +async fn migrate_v2(client: &PgClient) -> OpenFangResult<()> { + // PostgreSQL supports ADD COLUMN IF NOT EXISTS + client + .batch_execute( + " + ALTER TABLE task_queue ADD COLUMN IF NOT EXISTS title TEXT DEFAULT ''; + ALTER TABLE task_queue ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''; + ALTER TABLE task_queue ADD COLUMN IF NOT EXISTS assigned_to TEXT DEFAULT ''; + ALTER TABLE task_queue ADD COLUMN IF NOT EXISTS created_by TEXT DEFAULT ''; + ALTER TABLE task_queue ADD COLUMN IF NOT EXISTS result TEXT DEFAULT ''; + CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status); + + INSERT INTO migrations (version, applied_at, description) + VALUES (2, NOW(), 'Add collaboration columns to task_queue') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v2 failed: {e}")))?; + Ok(()) +} + +/// Version 3: Add embedding column to memories table. +async fn migrate_v3(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + ALTER TABLE memories ADD COLUMN IF NOT EXISTS embedding vector; + + INSERT INTO migrations (version, applied_at, description) + VALUES (3, NOW(), 'Add embedding column to memories') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v3 failed: {e}")))?; + Ok(()) +} + +/// Version 4: Add usage_events table for cost tracking. +async fn migrate_v4(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + CREATE TABLE IF NOT EXISTS usage_events ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + agent_id TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + model TEXT NOT NULL, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0, + tool_calls BIGINT NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_usage_agent_time ON usage_events(agent_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage_events(timestamp); + + INSERT INTO migrations (version, applied_at, description) + VALUES (4, NOW(), 'Add usage_events table for cost tracking') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v4 failed: {e}")))?; + Ok(()) +} + +/// Version 5: Add canonical_sessions table for cross-channel memory. +async fn migrate_v5(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + CREATE TABLE IF NOT EXISTS canonical_sessions ( + agent_id TEXT PRIMARY KEY, + messages BYTEA NOT NULL, + compaction_cursor INTEGER NOT NULL DEFAULT 0, + compacted_summary TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + INSERT INTO migrations (version, applied_at, description) + VALUES (5, NOW(), 'Add canonical_sessions for cross-channel memory') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v5 failed: {e}")))?; + Ok(()) +} + +/// Version 6: Add label column to sessions table. +async fn migrate_v6(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT; + + INSERT INTO migrations (version, applied_at, description) + VALUES (6, NOW(), 'Add label column to sessions') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v6 failed: {e}")))?; + Ok(()) +} + +/// Version 7: Add paired_devices table. +async fn migrate_v7(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + CREATE TABLE IF NOT EXISTS paired_devices ( + device_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + paired_at TEXT NOT NULL DEFAULT '', + last_seen TEXT NOT NULL DEFAULT '', + push_token TEXT + ); + + INSERT INTO migrations (version, applied_at, description) + VALUES (7, NOW(), 'Add paired_devices table') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v7 failed: {e}")))?; + Ok(()) +} + +/// Version 8: Add audit_entries table for Merkle audit trail. +async fn migrate_v8(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + CREATE TABLE IF NOT EXISTS audit_entries ( + seq BIGSERIAL PRIMARY KEY, + timestamp TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + prev_hash TEXT NOT NULL DEFAULT '', + hash TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_entries(agent_id); + CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_entries(timestamp); + CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_entries(action); + CREATE INDEX IF NOT EXISTS idx_audit_agent_time ON audit_entries(agent_id, timestamp); + + INSERT INTO migrations (version, applied_at, description) + VALUES (8, NOW(), 'Add audit_entries table for Merkle audit trail') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v8 failed: {e}")))?; + Ok(()) +} + +/// Version 9: Add agent identity columns. +/// +/// Note: SQLite v9 creates sqlite-vec virtual table. PostgreSQL uses pgvector +/// (enabled in v3 via `vector` column type) so no equivalent is needed here. +/// Instead, v9 adds the agent identity columns that were created inline in +/// the original PG schema. +async fn migrate_v9(client: &PgClient) -> OpenFangResult<()> { + client + .batch_execute( + " + ALTER TABLE agents ADD COLUMN IF NOT EXISTS session_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE agents ADD COLUMN IF NOT EXISTS identity TEXT NOT NULL DEFAULT '{}'; + + INSERT INTO migrations (version, applied_at, description) + VALUES (9, NOW(), 'Add agent session_id and identity columns') + ON CONFLICT (version) DO NOTHING; + ", + ) + .await + .map_err(|e| OpenFangError::Memory(format!("PG migration v9 failed: {e}")))?; + Ok(()) +} diff --git a/crates/openfang-memory/src/postgres/mod.rs b/crates/openfang-memory/src/postgres/mod.rs new file mode 100644 index 0000000000..0f078696fb --- /dev/null +++ b/crates/openfang-memory/src/postgres/mod.rs @@ -0,0 +1,94 @@ +//! PostgreSQL backend for the OpenFang memory layer. +//! +//! Uses `tokio-postgres` with `deadpool-postgres` for connection pooling +//! and `pgvector` for vector similarity search. +//! +//! Enable with `cargo build --features postgres`. + +pub mod audit; +pub mod consolidation; +pub mod knowledge; +pub mod migration; +pub mod paired_devices; +pub mod semantic; +pub mod session; +pub mod structured; +pub mod task_queue; +pub mod usage; + +pub use audit::PgAuditStore; +pub use consolidation::PgConsolidationEngine; +pub use knowledge::PgKnowledgeStore; +pub use migration::run_migrations; +pub use paired_devices::PgPairedDevicesStore; +pub use semantic::PostgresSemanticStore; +pub use session::PgSessionStore; +pub use structured::PgStructuredStore; +pub use task_queue::PgTaskQueueStore; +pub use usage::PgUsageStore; + +use deadpool_postgres::{Config, Pool, Runtime}; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use tokio_postgres::NoTls; + +/// Create a connection pool from a PostgreSQL URL. +pub fn create_pool(url: &str, pool_size: u32) -> OpenFangResult { + let mut cfg = Config::new(); + cfg.url = Some(url.to_string()); + cfg.pool = Some(deadpool_postgres::PoolConfig::new(pool_size as usize)); + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| OpenFangError::Memory(format!("Failed to create PostgreSQL pool: {e}"))) +} + +/// Convenience factory that creates all PostgreSQL-backed stores from a single pool. +pub struct PgBackend { + pool: Pool, +} + +impl PgBackend { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + pub fn structured(&self) -> PgStructuredStore { + PgStructuredStore::new(self.pool.clone()) + } + + pub fn semantic(&self) -> PostgresSemanticStore { + PostgresSemanticStore::new(self.pool.clone()) + } + + /// Access the underlying pool. Useful when composing Postgres-backed + /// semantic storage on top of a SQLite structured backend. + pub fn pool(&self) -> &Pool { + &self.pool + } + + pub fn knowledge(&self) -> PgKnowledgeStore { + PgKnowledgeStore::new(self.pool.clone()) + } + + pub fn session(&self) -> PgSessionStore { + PgSessionStore::new(self.pool.clone()) + } + + pub fn usage(&self) -> PgUsageStore { + PgUsageStore::new(self.pool.clone()) + } + + pub fn paired_devices(&self) -> PgPairedDevicesStore { + PgPairedDevicesStore::new(self.pool.clone()) + } + + pub fn task_queue(&self) -> PgTaskQueueStore { + PgTaskQueueStore::new(self.pool.clone()) + } + + pub fn consolidation(&self) -> PgConsolidationEngine { + PgConsolidationEngine::new(self.pool.clone()) + } + + pub fn audit(&self) -> PgAuditStore { + PgAuditStore::new(self.pool.clone()) + } +} diff --git a/crates/openfang-memory/src/postgres/paired_devices.rs b/crates/openfang-memory/src/postgres/paired_devices.rs new file mode 100644 index 0000000000..bd2773c3b9 --- /dev/null +++ b/crates/openfang-memory/src/postgres/paired_devices.rs @@ -0,0 +1,112 @@ +//! PostgreSQL implementation of the paired devices store. + +use crate::backends::PairedDevicesBackend; +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; + +pub struct PgPairedDevicesStore { + pool: Pool, +} + +impl PgPairedDevicesStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f)) + } +} + +impl PairedDevicesBackend for PgPairedDevicesStore { + fn load_paired_devices(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query( + "SELECT device_id, display_name, platform, paired_at, last_seen, push_token FROM paired_devices", + &[], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows + .iter() + .map(|r| { + serde_json::json!({ + "device_id": r.get::<_, String>(0), + "display_name": r.get::<_, String>(1), + "platform": r.get::<_, String>(2), + "paired_at": r.get::<_, String>(3), + "last_seen": r.get::<_, String>(4), + "push_token": r.get::<_, Option>(5), + }) + }) + .collect()) + }) + } + + fn save_paired_device( + &self, + device_id: &str, + display_name: &str, + platform: &str, + paired_at: &str, + last_seen: &str, + push_token: Option<&str>, + ) -> OpenFangResult<()> { + let device_id = device_id.to_string(); + let display_name = display_name.to_string(); + let platform = platform.to_string(); + let paired_at = paired_at.to_string(); + let last_seen = last_seen.to_string(); + let push_token = push_token.map(|s| s.to_string()); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO paired_devices (device_id, display_name, platform, paired_at, last_seen, push_token) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (device_id) DO UPDATE SET + display_name = EXCLUDED.display_name, + platform = EXCLUDED.platform, + paired_at = EXCLUDED.paired_at, + last_seen = EXCLUDED.last_seen, + push_token = EXCLUDED.push_token", + &[&device_id, &display_name, &platform, &paired_at, &last_seen, &push_token], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn remove_paired_device(&self, device_id: &str) -> OpenFangResult<()> { + let device_id = device_id.to_string(); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "DELETE FROM paired_devices WHERE device_id = $1", + &[&device_id], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/semantic.rs b/crates/openfang-memory/src/postgres/semantic.rs new file mode 100644 index 0000000000..b3fa4b3144 --- /dev/null +++ b/crates/openfang-memory/src/postgres/semantic.rs @@ -0,0 +1,239 @@ +//! PostgreSQL + pgvector implementation of the semantic store. +//! +//! Mirrors the semantics of [`crate::qdrant::QdrantSemanticStore`] using +//! pgvector for similarity search. `remember`/`recall`/`forget`/`update_embedding` +//! all operate against the shared `memories` table created by the +//! [`crate::postgres::migration`] module. + +use crate::helpers; +use deadpool_postgres::Pool; +use openfang_types::agent::AgentId; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySource}; +use openfang_types::storage::SemanticBackend; +use pgvector::Vector; +use std::collections::HashMap; + +/// PostgreSQL-backed semantic store with pgvector for similarity search. +/// +/// This is the Postgres equivalent of `QdrantSemanticStore`: it requires an +/// embedding for `recall()` (same as Qdrant) and uses cosine distance +/// (`<=>`) for vector ordering. +pub struct PostgresSemanticStore { + pool: Pool, +} + +impl PostgresSemanticStore { + /// Create a new PostgreSQL-backed semantic store from an existing pool. + /// + /// The caller is expected to have already run `run_migrations` on the pool + /// so that the `memories` table (with its `embedding vector` column) exists. + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f)) + } +} + +impl SemanticBackend for PostgresSemanticStore { + fn remember( + &self, + agent_id: AgentId, + content: &str, + source: MemorySource, + scope: &str, + metadata: HashMap, + embedding: Option<&[f32]>, + ) -> OpenFangResult { + let id = MemoryId::new(); + let source_str = helpers::serialize_source(&source)?; + let meta_str = helpers::serialize_metadata(&metadata)?; + let vec_embedding = embedding.map(|e| Vector::from(e.to_vec())); + let content = content.to_string(); + let scope = scope.to_string(); + + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO memories (id, agent_id, content, source, scope, confidence, metadata, embedding, created_at, accessed_at, access_count, deleted) + VALUES ($1, $2, $3, $4, $5, 1.0, $6, $7, NOW(), NOW(), 0, FALSE)", + &[ + &id.0.to_string(), + &agent_id.0.to_string(), + &content, + &source_str, + &scope, + &meta_str, + &vec_embedding, + ], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(id) + }) + } + + fn recall( + &self, + _query: &str, + limit: usize, + filter: Option, + query_embedding: Option<&[f32]>, + ) -> OpenFangResult> { + // pgvector similarity search requires an embedding — mirror Qdrant's + // contract (embedding-required). Callers that want text fallback + // should use the SQLite semantic backend instead. + let vec_embedding = match query_embedding { + Some(e) => Vector::from(e.to_vec()), + None => { + return Err(OpenFangError::Memory( + "postgres semantic backend requires a query embedding for recall()".to_string(), + )); + } + }; + + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + // Build WHERE clause dynamically from filter, collecting owned params. + let mut conditions = vec!["deleted = FALSE".to_string()]; + let mut param_idx: u32 = 1; + let mut params: Vec> = Vec::new(); + + if let Some(ref f) = filter { + if let Some(agent_id) = f.agent_id { + conditions.push(format!("agent_id = ${param_idx}")); + params.push(Box::new(agent_id.0.to_string())); + param_idx += 1; + } + if let Some(ref scope) = f.scope { + conditions.push(format!("scope = ${param_idx}")); + params.push(Box::new(scope.clone())); + param_idx += 1; + } + if let Some(min_conf) = f.min_confidence { + conditions.push(format!("confidence >= ${param_idx}")); + params.push(Box::new(min_conf as f64)); + param_idx += 1; + } + } + + let where_clause = conditions.join(" AND "); + + // Cosine distance ordering: `<=>` in pgvector. + let sql = format!( + "SELECT id, agent_id, content, source, scope, confidence, metadata, created_at, accessed_at, access_count + FROM memories + WHERE {where_clause} AND embedding IS NOT NULL + ORDER BY embedding <=> ${param_idx} + LIMIT {limit}" + ); + + let mut final_params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params + .iter() + .map(|b| b.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + final_params.push(&vec_embedding); + + let rows = client + .query(&sql, &final_params) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut fragments = Vec::with_capacity(rows.len()); + for row in &rows { + let id_str: String = row.get(0); + let agent_str: String = row.get(1); + let content: String = row.get(2); + let source_str: String = row.get(3); + let scope: String = row.get(4); + let confidence: f32 = row.get(5); + let meta_str: String = row.get(6); + let created_at: chrono::DateTime = row.get(7); + let accessed_at: chrono::DateTime = row.get(8); + let access_count: i64 = row.get(9); + + let id = helpers::parse_memory_id(&id_str)?; + let agent_id = helpers::parse_agent_id(&agent_str)?; + let source: MemorySource = helpers::deserialize_source(&source_str); + let metadata: HashMap = + helpers::deserialize_metadata(&meta_str); + + fragments.push(MemoryFragment { + id, + agent_id, + content, + embedding: None, + metadata, + source, + confidence, + created_at, + accessed_at, + access_count: access_count as u64, + scope, + }); + + // Best-effort access bump; ignore errors. + let _ = client + .execute( + "UPDATE memories SET access_count = access_count + 1, accessed_at = NOW() WHERE id = $1", + &[&id_str], + ) + .await; + } + + Ok(fragments) + }) + } + + fn forget(&self, id: MemoryId) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "UPDATE memories SET deleted = TRUE WHERE id = $1", + &[&id.0.to_string()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn update_embedding(&self, id: MemoryId, embedding: &[f32]) -> OpenFangResult<()> { + let vec = Vector::from(embedding.to_vec()); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "UPDATE memories SET embedding = $1 WHERE id = $2", + &[&vec, &id.0.to_string()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/session.rs b/crates/openfang-memory/src/postgres/session.rs new file mode 100644 index 0000000000..c3e595d489 --- /dev/null +++ b/crates/openfang-memory/src/postgres/session.rs @@ -0,0 +1,245 @@ +//! PostgreSQL implementation of the session store. + +use crate::backends::SessionBackend; +use crate::helpers; +use crate::session::{CanonicalSession, Session}; +use deadpool_postgres::Pool; +use openfang_types::agent::{AgentId, SessionId}; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::message::Message; + +pub struct PgSessionStore { + pool: Pool, +} + +impl PgSessionStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) + } +} + +impl SessionBackend for PgSessionStore { + fn get_session(&self, id: SessionId) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client + .query_opt( + "SELECT id, agent_id, messages, context_window_tokens, label FROM sessions WHERE id = $1", + &[&id.0.to_string()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + match row { + Some(row) => { + let agent_str: String = row.get(1); + let messages_blob: Vec = row.get(2); + let tokens: i64 = row.get(3); + let label: Option = row.get(4); + let agent_id = helpers::parse_agent_id(&agent_str)?; + let messages: Vec = helpers::deserialize_messages_lossy(&messages_blob); + Ok(Some(Session { id, agent_id, messages, context_window_tokens: tokens as u64, label })) + } + None => Ok(None), + } + }) + } + + fn save_session(&self, session: &Session) -> OpenFangResult<()> { + let messages_blob = helpers::serialize_messages(&session.messages)?; + let session = session.clone(); + + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO sessions (id, agent_id, messages, context_window_tokens, label, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET messages = $3, context_window_tokens = $4, label = $5, updated_at = NOW()", + &[ + &session.id.0.to_string(), + &session.agent_id.0.to_string(), + &messages_blob, + &(session.context_window_tokens as i64), + &session.label, + ], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn delete_session(&self, id: SessionId) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute("DELETE FROM sessions WHERE id = $1", &[&id.0.to_string()]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute("DELETE FROM sessions WHERE agent_id = $1", &[&agent_id.0.to_string()]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn list_sessions(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query("SELECT id, agent_id, label, updated_at FROM sessions ORDER BY updated_at DESC", &[]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows.iter().map(|r| { + serde_json::json!({ + "id": r.get::<_, String>(0), + "agent_id": r.get::<_, String>(1), + "label": r.get::<_, Option>(2), + "updated_at": r.get::<_, chrono::DateTime>(3).to_rfc3339(), + }) + }).collect()) + }) + } + + fn list_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query( + "SELECT id, label, updated_at FROM sessions WHERE agent_id = $1 ORDER BY updated_at DESC", + &[&agent_id.0.to_string()], + ) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows.iter().map(|r| { + serde_json::json!({ + "id": r.get::<_, String>(0), + "agent_id": agent_id.0.to_string(), + "label": r.get::<_, Option>(1), + "updated_at": r.get::<_, chrono::DateTime>(2).to_rfc3339(), + }) + }).collect()) + }) + } + + fn set_session_label(&self, id: SessionId, label: Option<&str>) -> OpenFangResult<()> { + let label = label.map(|s| s.to_string()); + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute("UPDATE sessions SET label = $1 WHERE id = $2", &[&label, &id.0.to_string()]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn find_session_by_label(&self, agent_id: AgentId, label: &str) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client + .query_opt( + "SELECT id, messages, context_window_tokens, label FROM sessions WHERE agent_id = $1 AND label = $2", + &[&agent_id.0.to_string(), &label], + ) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + match row { + Some(row) => { + let id_str: String = row.get(0); + let messages_blob: Vec = row.get(1); + let tokens: i64 = row.get(2); + let label: Option = row.get(3); + let id = helpers::parse_session_id(&id_str)?; + let messages: Vec = helpers::deserialize_messages_lossy(&messages_blob); + Ok(Some(Session { id, agent_id, messages, context_window_tokens: tokens as u64, label })) + } + None => Ok(None), + } + }) + } + + fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute("DELETE FROM canonical_sessions WHERE agent_id = $1", &[&agent_id.0.to_string()]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn load_canonical(&self, agent_id: AgentId) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client + .query_opt( + "SELECT messages, compaction_cursor, compacted_summary, updated_at FROM canonical_sessions WHERE agent_id = $1", + &[&agent_id.0.to_string()], + ) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + match row { + Some(row) => { + let messages_blob: Vec = row.get(0); + let cursor: i32 = row.get(1); + let summary: Option = row.get(2); + let updated_at: chrono::DateTime = row.get(3); + let messages: Vec = helpers::deserialize_messages_lossy(&messages_blob); + Ok(CanonicalSession { + agent_id, messages, compaction_cursor: cursor as usize, + compacted_summary: summary, updated_at: updated_at.to_rfc3339(), + }) + } + None => { + // Auto-create + let empty = helpers::serialize_messages(&[])?; + client.execute( + "INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, updated_at) VALUES ($1, $2, 0, NOW())", + &[&agent_id.0.to_string(), &empty], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(CanonicalSession { + agent_id, messages: vec![], compaction_cursor: 0, + compacted_summary: None, updated_at: chrono::Utc::now().to_rfc3339(), + }) + } + } + }) + } + + fn save_canonical(&self, canonical: &CanonicalSession) -> OpenFangResult<()> { + let messages_blob = helpers::serialize_messages(&canonical.messages)?; + let agent_id_str = canonical.agent_id.0.to_string(); + let cursor = canonical.compaction_cursor as i32; + let summary = canonical.compacted_summary.clone(); + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute( + "INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, compacted_summary, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (agent_id) DO UPDATE SET messages = $2, compaction_cursor = $3, compacted_summary = $4, updated_at = NOW()", + &[&agent_id_str, &messages_blob, &cursor, &summary], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + +} diff --git a/crates/openfang-memory/src/postgres/structured.rs b/crates/openfang-memory/src/postgres/structured.rs new file mode 100644 index 0000000000..9d8967ef39 --- /dev/null +++ b/crates/openfang-memory/src/postgres/structured.rs @@ -0,0 +1,254 @@ +//! PostgreSQL implementation of the structured (KV + agent) store. + +use crate::helpers; +use deadpool_postgres::Pool; +use openfang_types::agent::{AgentEntry, AgentId}; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::storage::StructuredBackend; + +/// PostgreSQL-backed structured store. +pub struct PgStructuredStore { + pool: Pool, +} + +impl PgStructuredStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + // The backend traits are synchronous. Use tokio::runtime::Handle to + // bridge from sync to async when called from spawn_blocking context. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) + } +} + +impl StructuredBackend for PgStructuredStore { + fn get(&self, agent_id: AgentId, key: &str) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client + .query_opt( + "SELECT value FROM kv_store WHERE agent_id = $1 AND key = $2", + &[&agent_id.0.to_string(), &key], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + match row { + Some(row) => { + let blob: Vec = row.get(0); + let value: serde_json::Value = serde_json::from_slice(&blob) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + Ok(Some(value)) + } + None => Ok(None), + } + }) + } + + fn set(&self, agent_id: AgentId, key: &str, value: serde_json::Value) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let blob = serde_json::to_vec(&value) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + client + .execute( + "INSERT INTO kv_store (agent_id, key, value, version, updated_at) + VALUES ($1, $2, $3, 1, NOW()) + ON CONFLICT (agent_id, key) DO UPDATE SET value = $3, version = kv_store.version + 1, updated_at = NOW()", + &[&agent_id.0.to_string(), &key, &blob], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn delete(&self, agent_id: AgentId, key: &str) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "DELETE FROM kv_store WHERE agent_id = $1 AND key = $2", + &[&agent_id.0.to_string(), &key], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn list_kv(&self, agent_id: AgentId) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query( + "SELECT key, value FROM kv_store WHERE agent_id = $1 ORDER BY key", + &[&agent_id.0.to_string()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let mut pairs = Vec::new(); + for row in rows { + let key: String = row.get(0); + let blob: Vec = row.get(1); + let value: serde_json::Value = serde_json::from_slice(&blob) + .unwrap_or(serde_json::Value::Null); + pairs.push((key, value)); + } + Ok(pairs) + }) + } + + fn save_agent(&self, entry: &AgentEntry) -> OpenFangResult<()> { + let manifest_blob = helpers::serialize_manifest(&entry.manifest)?; + let state_str = serde_json::to_string(&entry.state) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let identity_json = serde_json::to_string(&entry.identity) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let entry_id = entry.id.0.to_string(); + let entry_name = entry.name.clone(); + let session_id = entry.session_id.0.to_string(); + + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO agents (id, name, manifest, state, session_id, identity, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET name = $2, manifest = $3, state = $4, session_id = $5, identity = $6, updated_at = NOW()", + &[&entry_id, &entry_name, &manifest_blob, &state_str, &session_id, &identity_json], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn load_agent(&self, agent_id: AgentId) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client + .query_opt( + "SELECT id, name, manifest, state, created_at, session_id, identity FROM agents WHERE id = $1", + &[&agent_id.0.to_string()], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + match row { + Some(row) => { + let name: String = row.get(1); + let manifest_blob: Vec = row.get(2); + let state_str: String = row.get(3); + let created_at: chrono::DateTime = row.get(4); + let session_id_str: String = row.get(5); + let identity_str: String = row.get(6); + + let manifest = helpers::deserialize_manifest(&manifest_blob)?; + let state = serde_json::from_str(&state_str) + .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let session_id = helpers::parse_session_id(&session_id_str) + .unwrap_or_else(|_| openfang_types::agent::SessionId::new()); + let identity = serde_json::from_str(&identity_str).unwrap_or_default(); + + Ok(Some(AgentEntry { + id: agent_id, name, manifest, state, + mode: Default::default(), created_at, + last_active: chrono::Utc::now(), + parent: None, children: vec![], session_id, + tags: vec![], identity, + onboarding_completed: false, onboarding_completed_at: None, + })) + } + None => Ok(None), + } + }) + } + + fn remove_agent(&self, agent_id: AgentId) -> OpenFangResult<()> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute("DELETE FROM agents WHERE id = $1", &[&agent_id.0.to_string()]) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn load_all_agents(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query( + "SELECT id, name, manifest, state, created_at, session_id, identity FROM agents", + &[], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut agents = Vec::new(); + for row in rows { + let id_str: String = row.get(0); + let name: String = row.get(1); + let manifest_blob: Vec = row.get(2); + let state_str: String = row.get(3); + let created_at: chrono::DateTime = row.get(4); + let session_id_str: String = row.get(5); + let identity_str: String = row.get(6); + + let agent_id = match helpers::parse_agent_id(&id_str) { + Ok(id) => id, + Err(_) => continue, + }; + let manifest = match helpers::deserialize_manifest(&manifest_blob) { + Ok(m) => m, + Err(_) => continue, + }; + let state = match serde_json::from_str(&state_str) { + Ok(s) => s, + Err(_) => continue, + }; + let session_id = helpers::parse_session_id(&session_id_str) + .unwrap_or_else(|_| openfang_types::agent::SessionId::new()); + let identity = serde_json::from_str(&identity_str).unwrap_or_default(); + + agents.push(AgentEntry { + id: agent_id, name, manifest, state, + mode: Default::default(), created_at, + last_active: chrono::Utc::now(), + parent: None, children: vec![], session_id, + tags: vec![], identity, + onboarding_completed: false, onboarding_completed_at: None, + }); + } + Ok(agents) + }) + } + + fn list_agents(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .query("SELECT id, name, state FROM agents", &[]) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows.iter().map(|r| (r.get(0), r.get(1), r.get(2))).collect()) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/task_queue.rs b/crates/openfang-memory/src/postgres/task_queue.rs new file mode 100644 index 0000000000..c764ce8c6b --- /dev/null +++ b/crates/openfang-memory/src/postgres/task_queue.rs @@ -0,0 +1,203 @@ +//! PostgreSQL implementation of the task queue store. + +use crate::backends::TaskQueueBackend; +use deadpool_postgres::Pool; +use openfang_types::error::{OpenFangError, OpenFangResult}; + +pub struct PgTaskQueueStore { + pool: Pool, +} + +impl PgTaskQueueStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f)) + } +} + +impl TaskQueueBackend for PgTaskQueueStore { + fn task_post( + &self, + title: &str, + description: &str, + assigned_to: &str, + created_by: &str, + ) -> OpenFangResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let title = title.to_string(); + let description = description.to_string(); + let assigned_to = assigned_to.to_string(); + let created_by = created_by.to_string(); + let id_clone = id.clone(); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client + .execute( + "INSERT INTO task_queue (id, agent_id, task_type, payload, status, priority, created_at, title, description, assigned_to, created_by) + VALUES ($1, $2, $3, $4, 'pending', 0, $5, $6, $7, $8, $9)", + &[ + &id_clone, + &created_by, + &title, + &Vec::::new() as &(dyn tokio_postgres::types::ToSql + Sync), + &now, + &title, + &description, + &assigned_to, + &created_by, + ], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + })?; + Ok(id) + } + + fn task_claim(&self, agent_id: &str) -> OpenFangResult> { + let agent_id = agent_id.to_string(); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let row = client + .query_opt( + "SELECT id, title, description, assigned_to, created_by, created_at + FROM task_queue + WHERE status = 'pending' AND (assigned_to = $1 OR assigned_to = '') + ORDER BY priority DESC, created_at ASC + LIMIT 1", + &[&agent_id], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + match row { + Some(r) => { + let id: String = r.get(0); + let title: String = r.get(1); + let description: String = r.get(2); + let assigned: String = r.get(3); + let created_by: String = r.get(4); + let created_at: String = r.get(5); + + // Update status to in_progress + client + .execute( + "UPDATE task_queue SET status = 'in_progress', assigned_to = $2 WHERE id = $1", + &[&id, &agent_id], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let display_assigned = if assigned.is_empty() { + agent_id.clone() + } else { + assigned + }; + + Ok(Some(serde_json::json!({ + "id": id, + "title": title, + "description": description, + "status": "in_progress", + "assigned_to": display_assigned, + "created_by": created_by, + "created_at": created_at, + }))) + } + None => Ok(None), + } + }) + } + + fn task_complete(&self, task_id: &str, result: &str) -> OpenFangResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let task_id = task_id.to_string(); + let result = result.to_string(); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client + .execute( + "UPDATE task_queue SET status = 'completed', result = $2, completed_at = $3 WHERE id = $1", + &[&task_id, &result, &now], + ) + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + if rows == 0 { + return Err(OpenFangError::Internal(format!( + "Task not found: {task_id}" + ))); + } + Ok(()) + }) + } + + fn task_list(&self, status: Option<&str>) -> OpenFangResult> { + let status = status.map(|s| s.to_string()); + self.block_on_pg(async { + let client = self + .pool + .get() + .await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = match &status { + Some(s) => { + client + .query( + "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result + FROM task_queue WHERE status = $1 ORDER BY created_at DESC", + &[s], + ) + .await + } + None => { + client + .query( + "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result + FROM task_queue ORDER BY created_at DESC", + &[], + ) + .await + } + } + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + Ok(rows + .iter() + .map(|r| { + serde_json::json!({ + "id": r.get::<_, String>(0), + "title": r.get::<_, String>(1), + "description": r.get::<_, String>(2), + "status": r.get::<_, String>(3), + "assigned_to": r.get::<_, String>(4), + "created_by": r.get::<_, String>(5), + "created_at": r.get::<_, String>(6), + "completed_at": r.get::<_, Option>(7), + "result": r.get::<_, Option>(8), + }) + }) + .collect()) + }) + } +} diff --git a/crates/openfang-memory/src/postgres/usage.rs b/crates/openfang-memory/src/postgres/usage.rs new file mode 100644 index 0000000000..1e74cd64b9 --- /dev/null +++ b/crates/openfang-memory/src/postgres/usage.rs @@ -0,0 +1,189 @@ +//! PostgreSQL implementation of the usage tracking store. + +use crate::backends::UsageBackend; +use crate::usage::{DailyBreakdown, ModelUsage, UsageRecord, UsageSummary}; +use deadpool_postgres::Pool; +use openfang_types::agent::AgentId; +use openfang_types::error::{OpenFangError, OpenFangResult}; + +pub struct PgUsageStore { + pool: Pool, +} + +impl PgUsageStore { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + fn block_on_pg(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) + } +} + +impl UsageBackend for PgUsageStore { + fn record(&self, record: &UsageRecord) -> OpenFangResult<()> { + let record = record.clone(); + self.block_on_pg(async { + let client = self.pool.get().await + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + client.execute( + "INSERT INTO usage_events (agent_id, model, input_tokens, output_tokens, cost_usd, tool_calls) + VALUES ($1, $2, $3, $4, $5, $6)", + &[ + &record.agent_id.0.to_string(), &record.model, + &(record.input_tokens as i64), &(record.output_tokens as i64), + &record.cost_usd, &(record.tool_calls as i64), + ], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + }) + } + + fn query_hourly(&self, agent_id: AgentId) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE agent_id = $1 AND timestamp > NOW() - INTERVAL '1 hour'", + &[&agent_id.0.to_string()], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn query_daily(&self, agent_id: AgentId) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE agent_id = $1 AND timestamp >= CURRENT_DATE", + &[&agent_id.0.to_string()], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn query_monthly(&self, agent_id: AgentId) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE agent_id = $1 AND timestamp >= date_trunc('month', CURRENT_DATE)", + &[&agent_id.0.to_string()], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn query_global_hourly(&self) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE timestamp > NOW() - INTERVAL '1 hour'", &[], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn query_global_monthly(&self) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE timestamp >= date_trunc('month', CURRENT_DATE)", &[], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn query_summary(&self, agent_id: Option) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let (sql, params): (&str, Vec>) = match agent_id { + Some(id) => ( + "SELECT COALESCE(SUM(input_tokens),0)::bigint, COALESCE(SUM(output_tokens),0)::bigint, COALESCE(SUM(cost_usd),0)::float8, COUNT(*)::bigint, COALESCE(SUM(tool_calls),0)::bigint FROM usage_events WHERE agent_id = $1", + vec![Box::new(id.0.to_string())], + ), + None => ( + "SELECT COALESCE(SUM(input_tokens),0)::bigint, COALESCE(SUM(output_tokens),0)::bigint, COALESCE(SUM(cost_usd),0)::float8, COUNT(*)::bigint, COALESCE(SUM(tool_calls),0)::bigint FROM usage_events", + vec![], + ), + }; + let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params.iter().map(|b| b.as_ref() as _).collect(); + let row = client.query_one(sql, ¶m_refs).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(UsageSummary { + total_input_tokens: row.get::<_, i64>(0) as u64, + total_output_tokens: row.get::<_, i64>(1) as u64, + total_cost_usd: row.get(2), + call_count: row.get::<_, i64>(3) as u64, + total_tool_calls: row.get::<_, i64>(4) as u64, + }) + }) + } + + fn query_by_model(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client.query( + "SELECT model, SUM(cost_usd)::float8, SUM(input_tokens)::bigint, SUM(output_tokens)::bigint, COUNT(*)::bigint + FROM usage_events GROUP BY model ORDER BY SUM(cost_usd) DESC", &[], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows.iter().map(|r| ModelUsage { + model: r.get(0), + total_cost_usd: r.get(1), + total_input_tokens: r.get::<_, i64>(2) as u64, + total_output_tokens: r.get::<_, i64>(3) as u64, + call_count: r.get::<_, i64>(4) as u64, + }).collect()) + }) + } + + fn query_daily_breakdown(&self, days: u32) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = client.query( + "SELECT timestamp::date::text, SUM(cost_usd)::float8, SUM(input_tokens + output_tokens)::bigint, COUNT(*)::bigint + FROM usage_events WHERE timestamp >= CURRENT_DATE - $1::integer * INTERVAL '1 day' + GROUP BY timestamp::date ORDER BY timestamp::date DESC", + &[&(days as i32)], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(rows.iter().map(|r| DailyBreakdown { + date: r.get(0), + cost_usd: r.get(1), + tokens: r.get::<_, i64>(2) as u64, + calls: r.get::<_, i64>(3) as u64, + }).collect()) + }) + } + + fn query_first_event_date(&self) -> OpenFangResult> { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_opt("SELECT MIN(timestamp)::text FROM usage_events", &[]) + .await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.and_then(|r| r.get(0))) + }) + } + + fn query_today_cost(&self) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let row = client.query_one( + "SELECT COALESCE(SUM(cost_usd), 0) FROM usage_events WHERE timestamp >= CURRENT_DATE", &[], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(row.get(0)) + }) + } + + fn cleanup_old(&self, days: u32) -> OpenFangResult { + self.block_on_pg(async { + let client = self.pool.get().await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + let deleted = client.execute( + "DELETE FROM usage_events WHERE timestamp < NOW() - $1::integer * INTERVAL '1 day'", + &[&(days as i32)], + ).await.map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(deleted as usize) + }) + } +} diff --git a/crates/openfang-memory/src/qdrant/mod.rs b/crates/openfang-memory/src/qdrant/mod.rs new file mode 100644 index 0000000000..ee07d4f472 --- /dev/null +++ b/crates/openfang-memory/src/qdrant/mod.rs @@ -0,0 +1,4 @@ +//! Qdrant backend for vector similarity search. + +mod semantic; +pub use semantic::QdrantSemanticStore; diff --git a/crates/openfang-memory/src/qdrant/semantic.rs b/crates/openfang-memory/src/qdrant/semantic.rs new file mode 100644 index 0000000000..0b008a1cee --- /dev/null +++ b/crates/openfang-memory/src/qdrant/semantic.rs @@ -0,0 +1,387 @@ +//! Qdrant-backed semantic store for vector similarity search. +//! +//! Uses the Qdrant gRPC client to store and search memory embeddings. +//! Non-vector metadata (content, source, scope, etc.) is stored as payload. +//! +//! Enable with `cargo build --features qdrant`. + +use crate::helpers; +use openfang_types::agent::AgentId; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySource}; +use openfang_types::storage::SemanticBackend; +use qdrant_client::qdrant::{ + point_id::PointIdOptions, Condition, CreateCollectionBuilder, DeletePointsBuilder, Distance, + Filter, PointId, PointStruct, SearchPointsBuilder, UpsertPointsBuilder, VectorParamsBuilder, +}; +use qdrant_client::Qdrant; +use std::collections::HashMap; +use tracing::{info, warn}; + +/// Extract a string from a Qdrant payload Value. +fn payload_str<'a>( + payload: &'a HashMap, + key: &str, +) -> Option<&'a str> { + payload.get(key).and_then(|v| { + if let Some(qdrant_client::qdrant::value::Kind::StringValue(s)) = &v.kind { + Some(s.as_str()) + } else { + None + } + }) +} + +/// Extract a double from a Qdrant payload Value. +fn payload_double( + payload: &HashMap, + key: &str, +) -> Option { + payload.get(key).and_then(|v| { + if let Some(qdrant_client::qdrant::value::Kind::DoubleValue(d)) = &v.kind { + Some(*d) + } else { + None + } + }) +} + +/// Extract an integer from a Qdrant payload Value. +fn payload_int( + payload: &HashMap, + key: &str, +) -> Option { + payload.get(key).and_then(|v| { + if let Some(qdrant_client::qdrant::value::Kind::IntegerValue(i)) = &v.kind { + Some(*i) + } else { + None + } + }) +} + +/// Qdrant-backed semantic store. +pub struct QdrantSemanticStore { + client: Qdrant, + collection: String, + /// Embedding dimensions (detected from first insert, then cached). + dims: std::sync::Mutex>, +} + +impl QdrantSemanticStore { + /// Create a new Qdrant semantic store. + /// + /// `url` is the Qdrant gRPC endpoint (e.g., `http://localhost:6334`). + /// `api_key` is optional for authenticated deployments. + /// `collection` is the Qdrant collection name. + /// + /// Performs a live `health_check` against the Qdrant server and returns + /// `Err` if the server cannot be reached — callers (substrate init) use + /// this to fail fast instead of silently degrading. + pub async fn new( + url: &str, + api_key: Option<&str>, + collection: &str, + ) -> OpenFangResult { + let mut builder = Qdrant::from_url(url); + if let Some(key) = api_key { + builder = builder.api_key(key); + } + let client = builder + .build() + .map_err(|e| OpenFangError::Memory(format!("Failed to create Qdrant client: {e}")))?; + + // Fail-fast probe: prove we can talk to the Qdrant server before + // returning a handle. `health_check` is a cheap server ping. + client.health_check().await.map_err(|e| { + OpenFangError::Memory(format!("Qdrant health check failed at {url}: {e}")) + })?; + + Ok(Self { + client, + collection: collection.to_string(), + dims: std::sync::Mutex::new(None), + }) + } + + /// Ensure the collection exists, creating it if needed. + fn ensure_collection(&self, dims: u64) -> OpenFangResult<()> { + { + let mut cached = self + .dims + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + if *cached == Some(dims) { + return Ok(()); + } + *cached = Some(dims); + } + + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(async { + let exists = self + .client + .collection_exists(&self.collection) + .await + .map_err(|e| { + OpenFangError::Memory(format!("Qdrant collection check failed: {e}")) + })?; + + if !exists { + self.client + .create_collection( + CreateCollectionBuilder::new(&self.collection) + .vectors_config(VectorParamsBuilder::new(dims, Distance::Cosine)), + ) + .await + .map_err(|e| { + OpenFangError::Memory(format!("Qdrant create collection failed: {e}")) + })?; + info!(collection = %self.collection, dims, "Created Qdrant collection"); + } + + Ok(()) + })) + } + + fn block_on(&self, f: F) -> OpenFangResult + where + F: std::future::Future>, + { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(f) + }) + } +} + +impl SemanticBackend for QdrantSemanticStore { + fn remember( + &self, + agent_id: AgentId, + content: &str, + source: MemorySource, + scope: &str, + metadata: HashMap, + embedding: Option<&[f32]>, + ) -> OpenFangResult { + let id = MemoryId::new(); + + let embedding = match embedding { + Some(e) => e, + None => { + return Err(OpenFangError::Memory( + "Qdrant backend requires embeddings for remember()".to_string(), + )); + } + }; + + self.ensure_collection(embedding.len() as u64)?; + + let source_str = helpers::serialize_source(&source)?; + let meta_str = helpers::serialize_metadata(&metadata)?; + let now = chrono::Utc::now().to_rfc3339(); + + let payload: HashMap = HashMap::from([ + ("agent_id".into(), agent_id.0.to_string().into()), + ("content".into(), content.to_string().into()), + ("source".into(), source_str.into()), + ("scope".into(), scope.to_string().into()), + ("confidence".into(), (1.0f64).into()), + ("metadata".into(), meta_str.into()), + ("created_at".into(), now.clone().into()), + ("accessed_at".into(), now.into()), + ("access_count".into(), 0i64.into()), + ]); + + let point = PointStruct::new(id.0.to_string(), embedding.to_vec(), payload); + + self.block_on(async { + self.client + .upsert_points(UpsertPointsBuilder::new(&self.collection, vec![point])) + .await + .map_err(|e| OpenFangError::Memory(format!("Qdrant upsert failed: {e}")))?; + Ok(id) + }) + } + + fn recall( + &self, + _query: &str, + limit: usize, + filter: Option, + query_embedding: Option<&[f32]>, + ) -> OpenFangResult> { + let embedding = match query_embedding { + Some(e) => e.to_vec(), + None => { + return Err(OpenFangError::Memory( + "Qdrant semantic backend requires a query_embedding for recall(); \ + enable an embedder or use a different semantic_backend" + .into(), + )) + } + }; + + let mut conditions = Vec::new(); + if let Some(ref f) = filter { + if let Some(agent_id) = f.agent_id { + conditions.push(Condition::matches("agent_id", agent_id.0.to_string())); + } + if let Some(ref scope) = f.scope { + conditions.push(Condition::matches("scope", scope.clone())); + } + } + + let qdrant_filter = if conditions.is_empty() { + None + } else { + Some(Filter::must(conditions)) + }; + + self.block_on(async { + let mut search = + SearchPointsBuilder::new(&self.collection, embedding, limit as u64) + .with_payload(true); + if let Some(f) = qdrant_filter { + search = search.filter(f); + } + + let results = self + .client + .search_points(search) + .await + .map_err(|e| OpenFangError::Memory(format!("Qdrant search failed: {e}")))?; + + let mut fragments = Vec::with_capacity(results.result.len()); + for point in &results.result { + let payload = &point.payload; + + // Extract UUID from PointId. Missing or non-UUID ids are a + // protocol-level corruption (Qdrant lost the point identity) — + // do not fabricate a replacement; drop with a warning. + let id_str = point + .id + .as_ref() + .and_then(|pid| match &pid.point_id_options { + Some(PointIdOptions::Uuid(u)) => Some(u.clone()), + Some(PointIdOptions::Num(n)) => Some(n.to_string()), + None => None, + }); + let id_str = match id_str { + Some(s) => s, + None => { + warn!( + error = "missing point_id", + "dropping Qdrant result with malformed payload" + ); + continue; + } + }; + let id = match helpers::parse_memory_id(&id_str) { + Ok(id) => id, + Err(e) => { + warn!( + error = %e, + id = %id_str, + "dropping Qdrant result with malformed payload" + ); + continue; + } + }; + + let agent_str = match payload_str(payload, "agent_id") { + Some(s) => s, + None => { + warn!( + error = "missing agent_id", + "dropping Qdrant result with malformed payload" + ); + continue; + } + }; + let agent_id = match helpers::parse_agent_id(agent_str) { + Ok(a) => a, + Err(e) => { + warn!( + error = %e, + agent_id = %agent_str, + "dropping Qdrant result with malformed payload" + ); + continue; + } + }; + + let content = payload_str(payload, "content") + .unwrap_or("") + .to_string(); + let source_str = payload_str(payload, "source").unwrap_or("\"System\""); + let source: MemorySource = helpers::deserialize_source(source_str); + let scope = payload_str(payload, "scope") + .unwrap_or("episodic") + .to_string(); + let confidence = payload_double(payload, "confidence").unwrap_or(1.0) as f32; + let meta_str = payload_str(payload, "metadata").unwrap_or("{}"); + let metadata: HashMap = + helpers::deserialize_metadata(meta_str); + let created_at = payload_str(payload, "created_at") + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(chrono::Utc::now); + let accessed_at = payload_str(payload, "accessed_at") + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(chrono::Utc::now); + let access_count = payload_int(payload, "access_count").unwrap_or(0) as u64; + + fragments.push(MemoryFragment { + id, + agent_id, + content, + embedding: None, + metadata, + source, + confidence, + created_at, + accessed_at, + access_count, + scope, + }); + } + + Ok(fragments) + }) + } + + fn forget(&self, id: MemoryId) -> OpenFangResult<()> { + self.block_on(async { + let point_id = PointId { + point_id_options: Some(PointIdOptions::Uuid(id.0.to_string())), + }; + self.client + .delete_points( + DeletePointsBuilder::new(&self.collection).points(vec![point_id]), + ) + .await + .map_err(|e| OpenFangError::Memory(format!("Qdrant delete failed: {e}")))?; + Ok(()) + }) + } + + fn update_embedding(&self, id: MemoryId, embedding: &[f32]) -> OpenFangResult<()> { + self.ensure_collection(embedding.len() as u64)?; + + self.block_on(async { + let point = PointStruct::new( + id.0.to_string(), + embedding.to_vec(), + HashMap::::new(), + ); + self.client + .upsert_points(UpsertPointsBuilder::new(&self.collection, vec![point])) + .await + .map_err(|e| { + OpenFangError::Memory(format!("Qdrant update embedding failed: {e}")) + })?; + Ok(()) + }) + } +} diff --git a/crates/openfang-memory/src/session.rs b/crates/openfang-memory/src/session.rs index f0d4c373d4..8b211f09b7 100644 --- a/crates/openfang-memory/src/session.rs +++ b/crates/openfang-memory/src/session.rs @@ -1,13 +1,7 @@ -//! Session management — load/save conversation history. +//! Session types for the memory layer. -use chrono::Utc; use openfang_types::agent::{AgentId, SessionId}; -use openfang_types::error::{OpenFangError, OpenFangResult}; -use openfang_types::message::{ContentBlock, Message, MessageContent, Role}; -use rusqlite::Connection; -use std::io::Write; -use std::path::Path; -use std::sync::{Arc, Mutex}; +use openfang_types::message::Message; /// A conversation session with message history. #[derive(Debug, Clone)] @@ -24,322 +18,6 @@ pub struct Session { pub label: Option, } -/// Session store backed by SQLite. -#[derive(Clone)] -pub struct SessionStore { - conn: Arc>, -} - -impl SessionStore { - /// Create a new session store wrapping the given connection. - pub fn new(conn: Arc>) -> Self { - Self { conn } - } - - /// Load a session from the database. - pub fn get_session(&self, session_id: SessionId) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let mut stmt = conn - .prepare("SELECT agent_id, messages, context_window_tokens, label FROM sessions WHERE id = ?1") - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let result = stmt.query_row(rusqlite::params![session_id.0.to_string()], |row| { - let agent_str: String = row.get(0)?; - let messages_blob: Vec = row.get(1)?; - let tokens: i64 = row.get(2)?; - let label: Option = row.get(3).unwrap_or(None); - Ok((agent_str, messages_blob, tokens, label)) - }); - - match result { - Ok((agent_str, messages_blob, tokens, label)) => { - let agent_id = uuid::Uuid::parse_str(&agent_str) - .map(AgentId) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let messages: Vec = rmp_serde::from_slice(&messages_blob) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - Ok(Some(Session { - id: session_id, - agent_id, - messages, - context_window_tokens: tokens as u64, - label, - })) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(OpenFangError::Memory(e.to_string())), - } - } - - /// Save a session to the database. - pub fn save_session(&self, session: &Session) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let messages_blob = rmp_serde::to_vec_named(&session.messages) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - let now = Utc::now().to_rfc3339(); - conn.execute( - "INSERT INTO sessions (id, agent_id, messages, context_window_tokens, label, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) - ON CONFLICT(id) DO UPDATE SET messages = ?3, context_window_tokens = ?4, label = ?5, updated_at = ?6", - rusqlite::params![ - session.id.0.to_string(), - session.agent_id.0.to_string(), - messages_blob, - session.context_window_tokens as i64, - session.label.as_deref(), - now, - ], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// Delete a session from the database. - pub fn delete_session(&self, session_id: SessionId) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - conn.execute( - "DELETE FROM sessions WHERE id = ?1", - rusqlite::params![session_id.0.to_string()], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// Delete all sessions belonging to an agent. - pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - conn.execute( - "DELETE FROM sessions WHERE agent_id = ?1", - rusqlite::params![agent_id.0.to_string()], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// Delete the canonical (cross-channel) session for an agent. - pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - conn.execute( - "DELETE FROM canonical_sessions WHERE agent_id = ?1", - rusqlite::params![agent_id.0.to_string()], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// List all sessions with metadata (session_id, agent_id, message_count, created_at). - pub fn list_sessions(&self) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let mut stmt = conn - .prepare( - "SELECT id, agent_id, messages, created_at, label FROM sessions ORDER BY created_at DESC", - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let rows = stmt - .query_map([], |row| { - let session_id: String = row.get(0)?; - let agent_id: String = row.get(1)?; - let messages_blob: Vec = row.get(2)?; - let created_at: String = row.get(3)?; - let label: Option = row.get(4)?; - // Deserialize just to count messages - let msg_count = rmp_serde::from_slice::>(&messages_blob) - .map(|m| m.len()) - .unwrap_or(0); - Ok(serde_json::json!({ - "session_id": session_id, - "agent_id": agent_id, - "message_count": msg_count, - "created_at": created_at, - "label": label, - })) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let mut sessions = Vec::new(); - for row in rows { - sessions.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(sessions) - } - - /// Create a new empty session for an agent. - pub fn create_session(&self, agent_id: AgentId) -> OpenFangResult { - let session = Session { - id: SessionId::new(), - agent_id, - messages: Vec::new(), - context_window_tokens: 0, - label: None, - }; - self.save_session(&session)?; - Ok(session) - } - - /// Set the label on an existing session. - pub fn set_session_label( - &self, - session_id: SessionId, - label: Option<&str>, - ) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - conn.execute( - "UPDATE sessions SET label = ?1, updated_at = ?2 WHERE id = ?3", - rusqlite::params![label, Utc::now().to_rfc3339(), session_id.0.to_string()], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// Find a session by label for a given agent. - pub fn find_session_by_label( - &self, - agent_id: AgentId, - label: &str, - ) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let mut stmt = conn - .prepare( - "SELECT id, messages, context_window_tokens, label FROM sessions \ - WHERE agent_id = ?1 AND label = ?2 LIMIT 1", - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let result = stmt.query_row(rusqlite::params![agent_id.0.to_string(), label], |row| { - let id_str: String = row.get(0)?; - let messages_blob: Vec = row.get(1)?; - let tokens: i64 = row.get(2)?; - let lbl: Option = row.get(3).unwrap_or(None); - Ok((id_str, messages_blob, tokens, lbl)) - }); - - match result { - Ok((id_str, messages_blob, tokens, lbl)) => { - let session_id = uuid::Uuid::parse_str(&id_str) - .map(SessionId) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let messages: Vec = rmp_serde::from_slice(&messages_blob) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - Ok(Some(Session { - id: session_id, - agent_id, - messages, - context_window_tokens: tokens as u64, - label: lbl, - })) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(OpenFangError::Memory(e.to_string())), - } - } -} - -impl SessionStore { - /// List all sessions for a specific agent. - pub fn list_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let mut stmt = conn - .prepare( - "SELECT id, messages, created_at, label FROM sessions WHERE agent_id = ?1 ORDER BY created_at DESC", - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let rows = stmt - .query_map(rusqlite::params![agent_id.0.to_string()], |row| { - let session_id: String = row.get(0)?; - let messages_blob: Vec = row.get(1)?; - let created_at: String = row.get(2)?; - let label: Option = row.get(3)?; - let msg_count = rmp_serde::from_slice::>(&messages_blob) - .map(|m| m.len()) - .unwrap_or(0); - Ok(serde_json::json!({ - "session_id": session_id, - "message_count": msg_count, - "created_at": created_at, - "label": label, - })) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let mut sessions = Vec::new(); - for row in rows { - sessions.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(sessions) - } - - /// Create a new session with an optional label. - pub fn create_session_with_label( - &self, - agent_id: AgentId, - label: Option<&str>, - ) -> OpenFangResult { - let session = Session { - id: SessionId::new(), - agent_id, - messages: Vec::new(), - context_window_tokens: 0, - label: label.map(|s| s.to_string()), - }; - self.save_session(&session)?; - Ok(session) - } - - /// Store an LLM-generated summary, replacing older messages with the summary - /// and keeping only the specified recent messages. - /// - /// This is used by the LLM-based compactor to replace text-truncation compaction - /// with an intelligent, LLM-generated summary of older conversation history. - pub fn store_llm_summary( - &self, - agent_id: AgentId, - summary: &str, - kept_messages: Vec, - ) -> OpenFangResult<()> { - let mut canonical = self.load_canonical(agent_id)?; - canonical.compacted_summary = Some(summary.to_string()); - canonical.messages = kept_messages; - canonical.compaction_cursor = 0; - canonical.updated_at = Utc::now().to_rfc3339(); - self.save_canonical(&canonical) - } -} - -/// Default number of recent messages to include from canonical session. -const DEFAULT_CANONICAL_WINDOW: usize = 50; - -/// Default compaction threshold: when message count exceeds this, compact older messages. -const DEFAULT_COMPACTION_THRESHOLD: usize = 100; - /// A canonical session stores persistent cross-channel context for an agent. /// /// Unlike regular sessions (one per channel interaction), there is one canonical @@ -358,456 +36,3 @@ pub struct CanonicalSession { /// Last update time. pub updated_at: String, } - -impl SessionStore { - /// Load the canonical session for an agent, creating one if it doesn't exist. - pub fn load_canonical(&self, agent_id: AgentId) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let mut stmt = conn - .prepare( - "SELECT messages, compaction_cursor, compacted_summary, updated_at \ - FROM canonical_sessions WHERE agent_id = ?1", - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let result = stmt.query_row(rusqlite::params![agent_id.0.to_string()], |row| { - let messages_blob: Vec = row.get(0)?; - let cursor: i64 = row.get(1)?; - let summary: Option = row.get(2)?; - let updated_at: String = row.get(3)?; - Ok((messages_blob, cursor, summary, updated_at)) - }); - - match result { - Ok((messages_blob, cursor, summary, updated_at)) => { - let messages: Vec = rmp_serde::from_slice(&messages_blob) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - Ok(CanonicalSession { - agent_id, - messages, - compaction_cursor: cursor as usize, - compacted_summary: summary, - updated_at, - }) - } - Err(rusqlite::Error::QueryReturnedNoRows) => { - let now = Utc::now().to_rfc3339(); - Ok(CanonicalSession { - agent_id, - messages: Vec::new(), - compaction_cursor: 0, - compacted_summary: None, - updated_at: now, - }) - } - Err(e) => Err(OpenFangError::Memory(e.to_string())), - } - } - - /// Append new messages to the canonical session and compact if over threshold. - /// - /// Compaction summarizes old messages into a text summary and trims the - /// message list. The `compaction_threshold` controls when this happens - /// (default: 100 messages). - pub fn append_canonical( - &self, - agent_id: AgentId, - new_messages: &[Message], - compaction_threshold: Option, - ) -> OpenFangResult { - let mut canonical = self.load_canonical(agent_id)?; - canonical.messages.extend(new_messages.iter().cloned()); - - let threshold = compaction_threshold.unwrap_or(DEFAULT_COMPACTION_THRESHOLD); - - // Compact if over threshold - if canonical.messages.len() > threshold { - let keep_count = DEFAULT_CANONICAL_WINDOW; - let to_compact = canonical.messages.len().saturating_sub(keep_count); - if to_compact > canonical.compaction_cursor { - // Build a summary from the messages being compacted - let compacting = &canonical.messages[canonical.compaction_cursor..to_compact]; - let mut summary_parts: Vec = Vec::new(); - if let Some(ref existing) = canonical.compacted_summary { - summary_parts.push(existing.clone()); - } - for msg in compacting { - let role = match msg.role { - openfang_types::message::Role::User => "User", - openfang_types::message::Role::Assistant => "Assistant", - openfang_types::message::Role::System => "System", - }; - let text = msg.content.text_content(); - if !text.is_empty() { - // Truncate individual messages in summary to keep it compact (UTF-8 safe) - let truncated = if text.len() > 200 { - format!("{}...", openfang_types::truncate_str(&text, 200)) - } else { - text - }; - summary_parts.push(format!("{role}: {truncated}")); - } - } - // Keep summary under ~4000 chars (UTF-8 safe) - let mut full_summary = summary_parts.join("\n"); - if full_summary.len() > 4000 { - let start = full_summary.len() - 4000; - // Find the next char boundary at or after `start` - let safe_start = (start..full_summary.len()) - .find(|&i| full_summary.is_char_boundary(i)) - .unwrap_or(full_summary.len()); - full_summary = full_summary[safe_start..].to_string(); - } - canonical.compacted_summary = Some(full_summary); - canonical.compaction_cursor = to_compact; - // Trim messages: keep only the recent window - canonical.messages = canonical.messages.split_off(to_compact); - canonical.compaction_cursor = 0; // reset cursor since we trimmed - } - } - - canonical.updated_at = Utc::now().to_rfc3339(); - self.save_canonical(&canonical)?; - Ok(canonical) - } - - /// Get recent messages from canonical session for context injection. - /// - /// Returns up to `window_size` recent messages (default 50), plus - /// the compacted summary if available. - pub fn canonical_context( - &self, - agent_id: AgentId, - window_size: Option, - ) -> OpenFangResult<(Option, Vec)> { - let canonical = self.load_canonical(agent_id)?; - let window = window_size.unwrap_or(DEFAULT_CANONICAL_WINDOW); - let start = canonical.messages.len().saturating_sub(window); - let recent = canonical.messages[start..].to_vec(); - Ok((canonical.compacted_summary.clone(), recent)) - } - - /// Persist a canonical session to SQLite. - fn save_canonical(&self, canonical: &CanonicalSession) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let messages_blob = rmp_serde::to_vec(&canonical.messages) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - conn.execute( - "INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, compacted_summary, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(agent_id) DO UPDATE SET messages = ?2, compaction_cursor = ?3, compacted_summary = ?4, updated_at = ?5", - rusqlite::params![ - canonical.agent_id.0.to_string(), - messages_blob, - canonical.compaction_cursor as i64, - canonical.compacted_summary, - canonical.updated_at, - ], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } -} - -/// A single JSONL line in the session mirror file. -#[derive(serde::Serialize)] -struct JsonlLine { - timestamp: String, - role: String, - content: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - tool_use: Option, -} - -impl SessionStore { - /// Write a human-readable JSONL mirror of a session to disk. - /// - /// Best-effort: errors are returned but should be logged and never - /// affect the primary SQLite store. - pub fn write_jsonl_mirror( - &self, - session: &Session, - sessions_dir: &Path, - ) -> Result<(), std::io::Error> { - std::fs::create_dir_all(sessions_dir)?; - let path = sessions_dir.join(format!("{}.jsonl", session.id.0)); - let mut file = std::fs::File::create(&path)?; - let now = Utc::now().to_rfc3339(); - - for msg in &session.messages { - let role_str = match msg.role { - Role::User => "user", - Role::Assistant => "assistant", - Role::System => "system", - }; - - let mut text_parts: Vec = Vec::new(); - let mut tool_parts: Vec = Vec::new(); - - match &msg.content { - MessageContent::Text(t) => { - text_parts.push(t.clone()); - } - MessageContent::Blocks(blocks) => { - for block in blocks { - match block { - ContentBlock::Text { text, .. } => { - text_parts.push(text.clone()); - } - ContentBlock::ToolUse { - id, name, input, .. - } => { - tool_parts.push(serde_json::json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": input, - })); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name: _, - content, - is_error, - } => { - tool_parts.push(serde_json::json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content, - "is_error": is_error, - })); - } - ContentBlock::Image { media_type, .. } => { - text_parts.push(format!("[image: {media_type}]")); - } - ContentBlock::Thinking { thinking, .. } => { - text_parts.push(format!( - "[thinking: {}]", - openfang_types::truncate_str(thinking, 200) - )); - } - ContentBlock::Unknown => {} - } - } - } - } - - let line = JsonlLine { - timestamp: now.clone(), - role: role_str.to_string(), - content: serde_json::Value::String(text_parts.join("\n")), - tool_use: if tool_parts.is_empty() { - None - } else { - Some(serde_json::Value::Array(tool_parts)) - }, - }; - - serde_json::to_writer(&mut file, &line).map_err(std::io::Error::other)?; - file.write_all(b"\n")?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::migration::run_migrations; - - fn setup() -> SessionStore { - let conn = Connection::open_in_memory().unwrap(); - run_migrations(&conn).unwrap(); - SessionStore::new(Arc::new(Mutex::new(conn))) - } - - #[test] - fn test_create_and_load_session() { - let store = setup(); - let agent_id = AgentId::new(); - let session = store.create_session(agent_id).unwrap(); - - let loaded = store.get_session(session.id).unwrap().unwrap(); - assert_eq!(loaded.agent_id, agent_id); - assert!(loaded.messages.is_empty()); - } - - #[test] - fn test_save_and_load_with_messages() { - let store = setup(); - let agent_id = AgentId::new(); - let mut session = store.create_session(agent_id).unwrap(); - session.messages.push(Message::user("Hello")); - session.messages.push(Message::assistant("Hi there!")); - store.save_session(&session).unwrap(); - - let loaded = store.get_session(session.id).unwrap().unwrap(); - assert_eq!(loaded.messages.len(), 2); - } - - #[test] - fn test_get_missing_session() { - let store = setup(); - let result = store.get_session(SessionId::new()).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_delete_session() { - let store = setup(); - let agent_id = AgentId::new(); - let session = store.create_session(agent_id).unwrap(); - let sid = session.id; - assert!(store.get_session(sid).unwrap().is_some()); - store.delete_session(sid).unwrap(); - assert!(store.get_session(sid).unwrap().is_none()); - } - - #[test] - fn test_delete_agent_sessions() { - let store = setup(); - let agent_id = AgentId::new(); - let s1 = store.create_session(agent_id).unwrap(); - let s2 = store.create_session(agent_id).unwrap(); - assert!(store.get_session(s1.id).unwrap().is_some()); - assert!(store.get_session(s2.id).unwrap().is_some()); - store.delete_agent_sessions(agent_id).unwrap(); - assert!(store.get_session(s1.id).unwrap().is_none()); - assert!(store.get_session(s2.id).unwrap().is_none()); - } - - #[test] - fn test_canonical_load_creates_empty() { - let store = setup(); - let agent_id = AgentId::new(); - let canonical = store.load_canonical(agent_id).unwrap(); - assert_eq!(canonical.agent_id, agent_id); - assert!(canonical.messages.is_empty()); - assert!(canonical.compacted_summary.is_none()); - assert_eq!(canonical.compaction_cursor, 0); - } - - #[test] - fn test_canonical_append_and_load() { - let store = setup(); - let agent_id = AgentId::new(); - - // Append from "Telegram" - let msgs1 = vec![ - Message::user("Hello from Telegram"), - Message::assistant("Hi! I'm your agent."), - ]; - store.append_canonical(agent_id, &msgs1, None).unwrap(); - - // Append from "Discord" - let msgs2 = vec![ - Message::user("Now I'm on Discord"), - Message::assistant("I remember you from Telegram!"), - ]; - let canonical = store.append_canonical(agent_id, &msgs2, None).unwrap(); - - // Should have all 4 messages - assert_eq!(canonical.messages.len(), 4); - } - - #[test] - fn test_canonical_context_window() { - let store = setup(); - let agent_id = AgentId::new(); - - // Add 10 messages - let msgs: Vec = (0..10) - .map(|i| Message::user(format!("Message {i}"))) - .collect(); - store.append_canonical(agent_id, &msgs, None).unwrap(); - - // Request window of 3 - let (summary, recent) = store.canonical_context(agent_id, Some(3)).unwrap(); - assert_eq!(recent.len(), 3); - assert!(summary.is_none()); // No compaction yet - } - - #[test] - fn test_canonical_compaction() { - let store = setup(); - let agent_id = AgentId::new(); - - // Add 120 messages (over the default 100 threshold) - let msgs: Vec = (0..120) - .map(|i| Message::user(format!("Message number {i} with some content"))) - .collect(); - let canonical = store.append_canonical(agent_id, &msgs, Some(100)).unwrap(); - - // After compaction: should keep DEFAULT_CANONICAL_WINDOW (50) messages - assert!(canonical.messages.len() <= 60); // some tolerance - assert!(canonical.compacted_summary.is_some()); - } - - #[test] - fn test_canonical_cross_channel_roundtrip() { - let store = setup(); - let agent_id = AgentId::new(); - - // Channel 1: user tells agent their name - store - .append_canonical( - agent_id, - &[ - Message::user("My name is Jaber"), - Message::assistant("Nice to meet you, Jaber!"), - ], - None, - ) - .unwrap(); - - // Channel 2: different channel queries same agent - let (summary, recent) = store.canonical_context(agent_id, None).unwrap(); - // The agent should have context about "Jaber" from the previous channel - let all_text: String = recent.iter().map(|m| m.content.text_content()).collect(); - assert!(all_text.contains("Jaber")); - assert!(summary.is_none()); // Only 2 messages, no compaction - } - - #[test] - fn test_jsonl_mirror_write() { - let store = setup(); - let agent_id = AgentId::new(); - let mut session = store.create_session(agent_id).unwrap(); - session - .messages - .push(openfang_types::message::Message::user("Hello")); - session - .messages - .push(openfang_types::message::Message::assistant("Hi there!")); - store.save_session(&session).unwrap(); - - let dir = tempfile::TempDir::new().unwrap(); - let sessions_dir = dir.path().join("sessions"); - store.write_jsonl_mirror(&session, &sessions_dir).unwrap(); - - let jsonl_path = sessions_dir.join(format!("{}.jsonl", session.id.0)); - assert!(jsonl_path.exists()); - - let content = std::fs::read_to_string(&jsonl_path).unwrap(); - let lines: Vec<&str> = content.trim().split('\n').collect(); - assert_eq!(lines.len(), 2); - - // Verify first line is user message - let line1: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(line1["role"], "user"); - assert_eq!(line1["content"], "Hello"); - - // Verify second line is assistant message - let line2: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); - assert_eq!(line2["role"], "assistant"); - assert_eq!(line2["content"], "Hi there!"); - assert!(line2.get("tool_use").is_none()); - } -} diff --git a/crates/openfang-memory/src/sqlite/audit.rs b/crates/openfang-memory/src/sqlite/audit.rs new file mode 100644 index 0000000000..edfe0c00e9 --- /dev/null +++ b/crates/openfang-memory/src/sqlite/audit.rs @@ -0,0 +1,129 @@ +//! SQLite implementation of the audit log store. +//! +//! Uses a SHA-256 Merkle hash chain matching the scheme in `openfang-runtime`'s +//! `AuditLog`, so entries written through this backend are chain-compatible with +//! entries written directly by the runtime. + +use crate::backends::AuditBackend; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use std::sync::{Arc, Mutex}; + +/// Audit-log store backed by SQLite with Merkle hash chain integrity. +#[derive(Clone)] +pub struct SqliteAuditStore { + conn: Arc>, +} + +impl SqliteAuditStore { + /// Create a new audit store wrapping the given connection. + pub fn new(conn: Arc>) -> Self { + Self { conn } + } +} + +/// Compute the SHA-256 hash for a single audit entry, matching the scheme in +/// `openfang-runtime::audit::compute_entry_hash`. +fn compute_entry_hash( + seq: u64, + timestamp: &str, + agent_id: &str, + action: &str, + detail: &str, + outcome: &str, + prev_hash: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(seq.to_string().as_bytes()); + hasher.update(timestamp.as_bytes()); + hasher.update(agent_id.as_bytes()); + hasher.update(action.as_bytes()); + hasher.update(detail.as_bytes()); + hasher.update(outcome.as_bytes()); + hasher.update(prev_hash.as_bytes()); + hex::encode(hasher.finalize()) +} + +impl AuditBackend for SqliteAuditStore { + fn append_entry( + &self, + agent_id: &str, + action: &str, + detail: &str, + outcome: &str, + ) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let now = chrono::Utc::now().to_rfc3339(); + + // Determine the next sequence number and the previous hash for chain continuity. + let (seq, prev_hash): (u64, String) = conn + .query_row( + "SELECT COALESCE(MAX(seq) + 1, 0), COALESCE((SELECT hash FROM audit_entries ORDER BY seq DESC LIMIT 1), ?1) FROM audit_entries", + rusqlite::params!["0".repeat(64)], + |row| Ok((row.get::<_, i64>(0)? as u64, row.get::<_, String>(1)?)), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let hash = compute_entry_hash(seq, &now, agent_id, action, detail, outcome, &prev_hash); + + conn.execute( + "INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![seq as i64, now, agent_id, action, detail, outcome, prev_hash, hash], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + Ok(()) + } + + fn load_entries( + &self, + agent_id: Option<&str>, + limit: usize, + ) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let (sql, params): (&str, Vec>) = match agent_id { + Some(aid) => ( + "SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries WHERE agent_id = ?1 ORDER BY seq DESC LIMIT ?2", + vec![Box::new(aid.to_string()), Box::new(limit as i64)], + ), + None => ( + "SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq DESC LIMIT ?1", + vec![Box::new(limit as i64)], + ), + }; + + let mut stmt = conn + .prepare(sql) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(serde_json::json!({ + "seq": row.get::<_, i64>(0)?, + "timestamp": row.get::<_, String>(1)?, + "agent_id": row.get::<_, String>(2)?, + "action": row.get::<_, String>(3)?, + "detail": row.get::<_, String>(4)?, + "outcome": row.get::<_, String>(5)?, + "prev_hash": row.get::<_, String>(6)?, + "hash": row.get::<_, String>(7)?, + })) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut entries = Vec::new(); + for row in rows { + entries.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(entries) + } +} diff --git a/crates/openfang-memory/src/consolidation.rs b/crates/openfang-memory/src/sqlite/consolidation.rs similarity index 92% rename from crates/openfang-memory/src/consolidation.rs rename to crates/openfang-memory/src/sqlite/consolidation.rs index 9d1d369fc6..cf59d148dd 100644 --- a/crates/openfang-memory/src/consolidation.rs +++ b/crates/openfang-memory/src/sqlite/consolidation.rs @@ -53,10 +53,16 @@ impl ConsolidationEngine { } } +impl crate::backends::ConsolidationBackend for ConsolidationEngine { + fn consolidate(&self) -> OpenFangResult { + ConsolidationEngine::consolidate(self) + } +} + #[cfg(test)] mod tests { use super::*; - use crate::migration::run_migrations; + use crate::sqlite::migration::run_migrations; fn setup() -> ConsolidationEngine { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/openfang-memory/src/knowledge.rs b/crates/openfang-memory/src/sqlite/knowledge.rs similarity index 68% rename from crates/openfang-memory/src/knowledge.rs rename to crates/openfang-memory/src/sqlite/knowledge.rs index e4f5c7734e..1405e1d6e6 100644 --- a/crates/openfang-memory/src/knowledge.rs +++ b/crates/openfang-memory/src/sqlite/knowledge.rs @@ -1,16 +1,14 @@ -//! Knowledge graph backed by SQLite. +//! SQLite backend for the knowledge graph. //! //! Stores entities and relations with support for graph pattern queries. +use crate::helpers; use chrono::Utc; use openfang_types::error::{OpenFangError, OpenFangResult}; -use openfang_types::memory::{ - Entity, EntityType, GraphMatch, GraphPattern, Relation, RelationType, -}; +use openfang_types::memory::{Entity, GraphMatch, GraphPattern, Relation}; +use openfang_types::storage::KnowledgeBackend; use rusqlite::Connection; -use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use uuid::Uuid; /// Knowledge graph store backed by SQLite. #[derive(Clone)] @@ -30,15 +28,9 @@ impl KnowledgeStore { .conn .lock() .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let id = if entity.id.is_empty() { - Uuid::new_v4().to_string() - } else { - entity.id.clone() - }; - let entity_type_str = serde_json::to_string(&entity.entity_type) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - let props_str = serde_json::to_string(&entity.properties) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let id = helpers::entity_id_or_generate(&entity.id); + let entity_type_str = helpers::serialize_entity_type(&entity.entity_type)?; + let props_str = helpers::serialize_properties(&entity.properties)?; let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO entities (id, entity_type, name, properties, created_at, updated_at) @@ -56,11 +48,9 @@ impl KnowledgeStore { .conn .lock() .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let id = Uuid::new_v4().to_string(); - let rel_type_str = serde_json::to_string(&relation.relation) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - let props_str = serde_json::to_string(&relation.properties) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let id = helpers::new_relation_id(); + let rel_type_str = helpers::serialize_relation_type(&relation.relation)?; + let props_str = helpers::serialize_properties(&relation.properties)?; let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO relations (id, source_entity, relation_type, target_entity, properties, confidence, created_at) @@ -89,7 +79,7 @@ impl KnowledgeStore { let mut sql = String::from( "SELECT s.id, s.entity_type, s.name, s.properties, s.created_at, s.updated_at, - r.id, r.source_entity, r.relation_type, r.target_entity, r.properties, r.confidence, r.created_at, + r.source_entity, r.relation_type, r.target_entity, r.properties, r.confidence, r.created_at, t.id, t.entity_type, t.name, t.properties, t.created_at, t.updated_at FROM relations r JOIN entities s ON r.source_entity = s.id @@ -106,8 +96,7 @@ impl KnowledgeStore { idx += 2; } if let Some(ref relation) = pattern.relation { - let rel_str = serde_json::to_string(relation) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let rel_str = helpers::serialize_relation_type(relation)?; sql.push_str(&format!(" AND r.relation_type = ?{idx}")); params.push(Box::new(rel_str)); idx += 1; @@ -137,19 +126,18 @@ impl KnowledgeStore { s_props: row.get(3)?, s_created: row.get(4)?, s_updated: row.get(5)?, - r_id: row.get(6)?, - r_source: row.get(7)?, - r_type: row.get(8)?, - r_target: row.get(9)?, - r_props: row.get(10)?, - r_confidence: row.get(11)?, - r_created: row.get(12)?, - t_id: row.get(13)?, - t_type: row.get(14)?, - t_name: row.get(15)?, - t_props: row.get(16)?, - t_created: row.get(17)?, - t_updated: row.get(18)?, + r_source: row.get(6)?, + r_type: row.get(7)?, + r_target: row.get(8)?, + r_props: row.get(9)?, + r_confidence: row.get(10)?, + r_created: row.get(11)?, + t_id: row.get(12)?, + t_type: row.get(13)?, + t_name: row.get(14)?, + t_props: row.get(15)?, + t_created: row.get(16)?, + t_updated: row.get(17)?, }) }) .map_err(|e| OpenFangError::Memory(e.to_string()))?; @@ -158,7 +146,7 @@ impl KnowledgeStore { for row_result in rows { let r = row_result.map_err(|e| OpenFangError::Memory(e.to_string()))?; matches.push(GraphMatch { - source: parse_entity( + source: helpers::build_entity( &r.s_id, &r.s_type, &r.s_name, @@ -166,7 +154,7 @@ impl KnowledgeStore { &r.s_created, &r.s_updated, ), - relation: parse_relation( + relation: helpers::build_relation( &r.r_source, &r.r_type, &r.r_target, @@ -174,7 +162,7 @@ impl KnowledgeStore { r.r_confidence, &r.r_created, ), - target: parse_entity( + target: helpers::build_entity( &r.t_id, &r.t_type, &r.t_name, @@ -188,6 +176,18 @@ impl KnowledgeStore { } } +impl KnowledgeBackend for KnowledgeStore { + fn add_entity(&self, entity: Entity) -> OpenFangResult { + KnowledgeStore::add_entity(self, entity) + } + fn add_relation(&self, relation: Relation) -> OpenFangResult { + KnowledgeStore::add_relation(self, relation) + } + fn query_graph(&self, pattern: GraphPattern) -> OpenFangResult> { + KnowledgeStore::query_graph(self, pattern) + } +} + /// Raw row from a graph query. struct RawGraphRow { s_id: String, @@ -196,7 +196,6 @@ struct RawGraphRow { s_props: String, s_created: String, s_updated: String, - r_id: String, r_source: String, r_type: String, r_target: String, @@ -211,70 +210,12 @@ struct RawGraphRow { t_updated: String, } -// Suppress the unused field warning — r_id is part of the schema -impl RawGraphRow { - #[allow(dead_code)] - fn relation_id(&self) -> &str { - &self.r_id - } -} - -fn parse_entity( - id: &str, - etype: &str, - name: &str, - props: &str, - created: &str, - updated: &str, -) -> Entity { - let entity_type: EntityType = - serde_json::from_str(etype).unwrap_or(EntityType::Custom("unknown".to_string())); - let properties: HashMap = - serde_json::from_str(props).unwrap_or_default(); - let created_at = chrono::DateTime::parse_from_rfc3339(created) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - let updated_at = chrono::DateTime::parse_from_rfc3339(updated) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - Entity { - id: id.to_string(), - entity_type, - name: name.to_string(), - properties, - created_at, - updated_at, - } -} - -fn parse_relation( - source: &str, - rtype: &str, - target: &str, - props: &str, - confidence: f64, - created: &str, -) -> Relation { - let relation: RelationType = serde_json::from_str(rtype).unwrap_or(RelationType::RelatedTo); - let properties: HashMap = - serde_json::from_str(props).unwrap_or_default(); - let created_at = chrono::DateTime::parse_from_rfc3339(created) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - Relation { - source: source.to_string(), - relation, - target: target.to_string(), - properties, - confidence: confidence as f32, - created_at, - } -} - #[cfg(test)] mod tests { use super::*; - use crate::migration::run_migrations; + use crate::sqlite::migration::run_migrations; + use openfang_types::memory::{EntityType, RelationType}; + use std::collections::HashMap; fn setup() -> KnowledgeStore { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/openfang-memory/src/migration.rs b/crates/openfang-memory/src/sqlite/migration.rs similarity index 82% rename from crates/openfang-memory/src/migration.rs rename to crates/openfang-memory/src/sqlite/migration.rs index 9249686510..c944f9bdc4 100644 --- a/crates/openfang-memory/src/migration.rs +++ b/crates/openfang-memory/src/sqlite/migration.rs @@ -5,7 +5,7 @@ use rusqlite::Connection; /// Current schema version. -const SCHEMA_VERSION: u32 = 8; +const SCHEMA_VERSION: u32 = 9; /// Run all migrations to bring the database up to date. pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { @@ -43,6 +43,10 @@ pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> { migrate_v8(conn)?; } + if current_version < 9 { + migrate_v9(conn)?; + } + set_schema_version(conn, SCHEMA_VERSION)?; Ok(()) } @@ -328,12 +332,64 @@ fn migrate_v8(conn: &Connection) -> Result<(), rusqlite::Error> { Ok(()) } +/// v9: Create sqlite-vec virtual table for indexed vector search. +/// +/// Detects embedding dimensions from existing data. If no embeddings exist yet, +/// the table is created lazily by `SemanticStore::ensure_vec_table()` on the +/// first `remember()` call with an embedding. +fn migrate_v9(conn: &Connection) -> Result<(), rusqlite::Error> { + // Detect embedding dimensions from existing data + let dims: Option = conn + .query_row( + "SELECT LENGTH(embedding) / 4 FROM memories WHERE embedding IS NOT NULL LIMIT 1", + [], + |row| row.get(0), + ) + .ok(); + + if let Some(dims) = dims { + // Create vec0 virtual table with detected dimensions + conn.execute_batch(&format!( + "CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding float[{dims}] + );" + ))?; + + // Backfill existing embeddings into the vec table + conn.execute_batch( + "INSERT OR IGNORE INTO memories_vec (memory_id, embedding) + SELECT id, embedding FROM memories + WHERE embedding IS NOT NULL AND deleted = 0;", + )?; + } + // If no embeddings exist yet, defer table creation to SemanticStore::ensure_vec_table() + + conn.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at, description) + VALUES (9, datetime('now'), 'Add sqlite-vec virtual table for indexed vector search')", + [], + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] fn test_migration_creates_tables() { + // Register sqlite-vec before opening connection + unsafe { + rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute::< + *const (), + unsafe extern "C" fn( + *mut rusqlite::ffi::sqlite3, + *mut *const std::os::raw::c_char, + *const rusqlite::ffi::sqlite3_api_routines, + ) -> i32, + >(sqlite_vec::sqlite3_vec_init as *const ()))); + } let conn = Connection::open_in_memory().unwrap(); run_migrations(&conn).unwrap(); @@ -356,6 +412,16 @@ mod tests { #[test] fn test_migration_idempotent() { + unsafe { + rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute::< + *const (), + unsafe extern "C" fn( + *mut rusqlite::ffi::sqlite3, + *mut *const std::os::raw::c_char, + *const rusqlite::ffi::sqlite3_api_routines, + ) -> i32, + >(sqlite_vec::sqlite3_vec_init as *const ()))); + } let conn = Connection::open_in_memory().unwrap(); run_migrations(&conn).unwrap(); run_migrations(&conn).unwrap(); // Should not error diff --git a/crates/openfang-memory/src/sqlite/mod.rs b/crates/openfang-memory/src/sqlite/mod.rs new file mode 100644 index 0000000000..0c7e331c85 --- /dev/null +++ b/crates/openfang-memory/src/sqlite/mod.rs @@ -0,0 +1,129 @@ +//! SQLite backend implementations for the OpenFang memory layer. + +pub mod audit; +pub mod consolidation; +pub mod knowledge; +pub mod migration; +pub mod paired_devices; +pub mod semantic; +pub mod session; +pub mod structured; +pub mod task_queue; +pub mod usage; + +pub use audit::SqliteAuditStore; +pub use consolidation::ConsolidationEngine; +pub use knowledge::KnowledgeStore; +pub use paired_devices::SqlitePairedDevicesStore; +pub use semantic::SemanticStore; +pub use session::SessionStore; +pub use structured::StructuredStore; +pub use task_queue::SqliteTaskQueueStore; +pub use usage::UsageStore; + +use openfang_types::error::{OpenFangError, OpenFangResult}; +use rusqlite::Connection; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// Factory that opens a single SQLite connection and hands out typed stores. +pub struct SqliteBackend { + conn: Arc>, +} + +impl SqliteBackend { + /// Open (or create) a database file, register sqlite-vec, apply PRAGMAs + /// and run migrations. + pub fn open(db_path: &Path) -> OpenFangResult { + // SAFETY: The transmute coerces `sqlite_vec::sqlite3_vec_init` (a Rust + // extern "C" fn with the crate's own pointer types) into the exact + // signature `sqlite3_auto_extension` requires from librusqlite's FFI. + // The signatures are ABI-compatible — this is the documented + // registration pattern for the `sqlite-vec` crate. The call itself is + // process-global and idempotent per SQLite's contract, so repeated + // invocations from different `open()` calls are safe. + unsafe { + rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute::< + *const (), + unsafe extern "C" fn( + *mut rusqlite::ffi::sqlite3, + *mut *const std::os::raw::c_char, + *const rusqlite::ffi::sqlite3_api_routines, + ) -> i32, + >(sqlite_vec::sqlite3_vec_init as *const ()))); + } + let conn = Connection::open(db_path).map_err(|e| OpenFangError::Memory(e.to_string()))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + migration::run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Open an in-memory database (for testing). + pub fn open_in_memory() -> OpenFangResult { + // SAFETY: Same as `open()` above — ABI-compatible transmute of the + // `sqlite-vec` init function pointer into the shape required by + // `sqlite3_auto_extension`. Idempotent and process-global. + unsafe { + rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute::< + *const (), + unsafe extern "C" fn( + *mut rusqlite::ffi::sqlite3, + *mut *const std::os::raw::c_char, + *const rusqlite::ffi::sqlite3_api_routines, + ) -> i32, + >(sqlite_vec::sqlite3_vec_init as *const ()))); + } + let conn = + Connection::open_in_memory().map_err(|e| OpenFangError::Memory(e.to_string()))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + migration::run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Return a clone of the shared connection handle. + pub fn conn(&self) -> Arc> { + Arc::clone(&self.conn) + } + + pub fn structured(&self) -> StructuredStore { + StructuredStore::new(Arc::clone(&self.conn)) + } + + pub fn semantic(&self) -> SemanticStore { + SemanticStore::new(Arc::clone(&self.conn)) + } + + pub fn knowledge(&self) -> KnowledgeStore { + KnowledgeStore::new(Arc::clone(&self.conn)) + } + + pub fn session(&self) -> SessionStore { + SessionStore::new(Arc::clone(&self.conn)) + } + + pub fn usage(&self) -> UsageStore { + UsageStore::new(Arc::clone(&self.conn)) + } + + pub fn paired_devices(&self) -> SqlitePairedDevicesStore { + SqlitePairedDevicesStore::new(Arc::clone(&self.conn)) + } + + pub fn task_queue(&self) -> SqliteTaskQueueStore { + SqliteTaskQueueStore::new(Arc::clone(&self.conn)) + } + + pub fn consolidation(&self, decay_rate: f32) -> ConsolidationEngine { + ConsolidationEngine::new(Arc::clone(&self.conn), decay_rate) + } + + pub fn audit(&self) -> SqliteAuditStore { + SqliteAuditStore::new(Arc::clone(&self.conn)) + } +} diff --git a/crates/openfang-memory/src/sqlite/paired_devices.rs b/crates/openfang-memory/src/sqlite/paired_devices.rs new file mode 100644 index 0000000000..e64debc0b6 --- /dev/null +++ b/crates/openfang-memory/src/sqlite/paired_devices.rs @@ -0,0 +1,84 @@ +//! SQLite implementation of the paired devices store. + +use crate::backends::PairedDevicesBackend; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; + +/// Paired-devices store backed by SQLite. +#[derive(Clone)] +pub struct SqlitePairedDevicesStore { + conn: Arc>, +} + +impl SqlitePairedDevicesStore { + /// Create a new paired-devices store wrapping the given connection. + pub fn new(conn: Arc>) -> Self { + Self { conn } + } +} + +impl PairedDevicesBackend for SqlitePairedDevicesStore { + fn load_paired_devices(&self) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let mut stmt = conn + .prepare( + "SELECT device_id, display_name, platform, paired_at, last_seen, push_token FROM paired_devices", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(serde_json::json!({ + "device_id": row.get::<_, String>(0)?, + "display_name": row.get::<_, String>(1)?, + "platform": row.get::<_, String>(2)?, + "paired_at": row.get::<_, String>(3)?, + "last_seen": row.get::<_, String>(4)?, + "push_token": row.get::<_, Option>(5)?, + })) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let mut devices = Vec::new(); + for row in rows { + devices.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(devices) + } + + fn save_paired_device( + &self, + device_id: &str, + display_name: &str, + platform: &str, + paired_at: &str, + last_seen: &str, + push_token: Option<&str>, + ) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + conn.execute( + "INSERT OR REPLACE INTO paired_devices (device_id, display_name, platform, paired_at, last_seen, push_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![device_id, display_name, platform, paired_at, last_seen, push_token], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + fn remove_paired_device(&self, device_id: &str) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + conn.execute( + "DELETE FROM paired_devices WHERE device_id = ?1", + rusqlite::params![device_id], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } +} diff --git a/crates/openfang-memory/src/semantic.rs b/crates/openfang-memory/src/sqlite/semantic.rs similarity index 60% rename from crates/openfang-memory/src/semantic.rs rename to crates/openfang-memory/src/sqlite/semantic.rs index 79d9ae4ba0..58fc74d43c 100644 --- a/crates/openfang-memory/src/semantic.rs +++ b/crates/openfang-memory/src/sqlite/semantic.rs @@ -1,4 +1,4 @@ -//! Semantic memory store with vector embedding support. +//! SQLite backend for semantic memory with vector embedding support. //! //! Phase 1: SQLite LIKE matching (fallback when no embeddings). //! Phase 2: Vector cosine similarity search using stored embeddings. @@ -7,50 +7,27 @@ //! When a query embedding is provided, recall uses cosine similarity ranking. //! When no embeddings are available, falls back to LIKE matching. +use crate::helpers; use chrono::Utc; use openfang_types::agent::AgentId; use openfang_types::error::{OpenFangError, OpenFangResult}; use openfang_types::memory::{MemoryFilter, MemoryFragment, MemoryId, MemorySource}; +use openfang_types::storage::SemanticBackend; use rusqlite::Connection; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tracing::{debug, warn}; +use tracing::debug; -#[cfg(feature = "http-memory")] -use crate::http_client::MemoryApiClient; - -/// Semantic store backed by SQLite with optional vector search. -/// -/// Supports two backends: -/// - **SQLite** (default): Local LIKE matching / cosine similarity. -/// - **HTTP**: Routes `remember`/`recall` to the memory-api gateway -/// (PostgreSQL + pgvector + Jina AI embeddings). +/// Semantic store backed by SQLite with vector search via sqlite-vec. #[derive(Clone)] pub struct SemanticStore { conn: Arc>, - #[cfg(feature = "http-memory")] - http_client: Option, } impl SemanticStore { - /// Create a new semantic store wrapping the given connection (SQLite backend). + /// Create a new semantic store wrapping the given connection. pub fn new(conn: Arc>) -> Self { - Self { - conn, - #[cfg(feature = "http-memory")] - http_client: None, - } - } - - /// Create a semantic store with an HTTP backend for the memory-api gateway. - /// - /// The SQLite connection is still required for local fallback and other stores. - #[cfg(feature = "http-memory")] - pub fn new_with_http(conn: Arc>, client: MemoryApiClient) -> Self { - Self { - conn, - http_client: Some(client), - } + Self { conn } } /// Store a new memory fragment (without embedding). @@ -66,9 +43,6 @@ impl SemanticStore { } /// Store a new memory fragment with an optional embedding vector. - /// - /// When HTTP backend is configured, stores via memory-api (which handles - /// embedding generation and deduplication). Falls back to local SQLite. pub fn remember_with_embedding( &self, agent_id: AgentId, @@ -78,13 +52,6 @@ impl SemanticStore { metadata: HashMap, embedding: Option<&[f32]>, ) -> OpenFangResult { - // HTTP backend: route to memory-api - #[cfg(feature = "http-memory")] - if let Some(ref client) = self.http_client { - return self.remember_via_http(client, agent_id, content, source, scope, &metadata); - } - - // SQLite backend (default) self.remember_sqlite(agent_id, content, source, scope, metadata, embedding) } @@ -104,10 +71,8 @@ impl SemanticStore { .map_err(|e| OpenFangError::Internal(e.to_string()))?; let id = MemoryId::new(); let now = Utc::now().to_rfc3339(); - let source_str = serde_json::to_string(&source) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; - let meta_str = serde_json::to_string(&metadata) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let source_str = helpers::serialize_source(&source)?; + let meta_str = helpers::serialize_metadata(&metadata)?; let embedding_bytes: Option> = embedding.map(embedding_to_bytes); conn.execute( @@ -125,47 +90,45 @@ impl SemanticStore { ], ) .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + // Dual-write to sqlite-vec virtual table for indexed vector search + if let Some(ref emb_bytes) = embedding_bytes { + if let Some(emb) = embedding { + let _ = Self::ensure_vec_table(&conn, emb.len()); + let _ = conn.execute( + "INSERT INTO memories_vec (memory_id, embedding) VALUES (?1, ?2)", + rusqlite::params![id.0.to_string(), emb_bytes], + ); + } + } + Ok(id) } - /// HTTP implementation of remember — routes to memory-api POST /memory/store. - #[cfg(feature = "http-memory")] - fn remember_via_http( - &self, - client: &MemoryApiClient, - agent_id: AgentId, - content: &str, - source: MemorySource, - scope: &str, - metadata: &HashMap, - ) -> OpenFangResult { - let source_str = format!("{:?}", source).to_lowercase(); - let importance = metadata - .get("importance") - .and_then(|v| v.as_u64()) - .map(|v| v.min(10) as u8) - .unwrap_or(5); - let tags: Option> = metadata - .get("tags") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - - match client.store( - content, - Some(scope), - Some(&agent_id.0.to_string()), - Some(&source_str), - Some(importance), - tags, - ) { - Ok(resp) => { - debug!(id = %resp.id, "Stored memory via HTTP backend"); - Ok(MemoryId::new()) - } - Err(e) => { - warn!(error = %e, "HTTP memory store failed, falling back to SQLite"); - self.remember_sqlite(agent_id, content, source, scope, metadata.clone(), None) - } + /// Ensure the sqlite-vec virtual table exists with the given dimensions. + /// Called lazily on the first write with an embedding. + fn ensure_vec_table( + conn: &Connection, + dims: usize, + ) -> Result<(), OpenFangError> { + let exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='memories_vec'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if !exists { + conn.execute_batch(&format!( + "CREATE VIRTUAL TABLE memories_vec USING vec0( + memory_id TEXT PRIMARY KEY, + embedding float[{dims}] + );" + )) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; } + Ok(()) } /// Search for memories using text matching (fallback, no embeddings). @@ -180,9 +143,6 @@ impl SemanticStore { /// Search for memories using vector similarity when a query embedding is provided, /// falling back to LIKE matching otherwise. - /// - /// When HTTP backend is configured, searches via memory-api (hybrid vector+BM25). - /// Falls back to local SQLite on HTTP errors. pub fn recall_with_embedding( &self, query: &str, @@ -190,25 +150,41 @@ impl SemanticStore { filter: Option, query_embedding: Option<&[f32]>, ) -> OpenFangResult> { - // HTTP backend: route to memory-api - #[cfg(feature = "http-memory")] - if let Some(ref client) = self.http_client { - match self.recall_via_http(client, query, limit, &filter) { - Ok(results) => return Ok(results), - Err(e) => { - warn!(error = %e, "HTTP memory search failed, falling back to SQLite"); - } - } - } - let conn = self .conn .lock() .map_err(|e| OpenFangError::Internal(e.to_string()))?; - // Build SQL: fetch candidates (broader than limit for vector re-ranking) + // Fast path: use sqlite-vec indexed search when query embedding is available + // and the vec table exists. + if let Some(qe) = query_embedding { + let vec_exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='memories_vec'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + + if vec_exists { + let result = self.recall_via_vec(&conn, qe, limit, &filter); + if let Ok(fragments) = result { + // Update access counts + for frag in &fragments { + let _ = conn.execute( + "UPDATE memories SET access_count = access_count + 1, accessed_at = ?1 WHERE id = ?2", + rusqlite::params![Utc::now().to_rfc3339(), frag.id.0.to_string()], + ); + } + return Ok(fragments); + } + // Fall through to brute-force on error + debug!("sqlite-vec recall failed, falling back to brute-force"); + } + } + + // Fallback: brute-force LIKE matching + optional cosine re-ranking let fetch_limit = if query_embedding.is_some() { - // Fetch more candidates for vector search re-ranking (limit * 10).max(100) } else { limit @@ -221,14 +197,12 @@ impl SemanticStore { let mut params: Vec> = Vec::new(); let mut param_idx = 1; - // Text search filter (only when no embeddings — vector search handles relevance) if query_embedding.is_none() && !query.is_empty() { sql.push_str(&format!(" AND content LIKE ?{param_idx}")); params.push(Box::new(format!("%{query}%"))); param_idx += 1; } - // Apply filters if let Some(ref f) = filter { if let Some(agent_id) = f.agent_id { sql.push_str(&format!(" AND agent_id = ?{param_idx}")); @@ -246,11 +220,11 @@ impl SemanticStore { param_idx += 1; } if let Some(ref source) = f.source { - let source_str = serde_json::to_string(source) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let source_str = helpers::serialize_source(source)?; sql.push_str(&format!(" AND source = ?{param_idx}")); params.push(Box::new(source_str)); - let _ = param_idx; + // No further increments — this is the last filter. Additional + // filters below must bump `param_idx` before using it. } } @@ -265,109 +239,53 @@ impl SemanticStore { params.iter().map(|p| p.as_ref()).collect(); let rows = stmt .query_map(param_refs.as_slice(), |row| { - let id_str: String = row.get(0)?; - let agent_str: String = row.get(1)?; - let content: String = row.get(2)?; - let source_str: String = row.get(3)?; - let scope: String = row.get(4)?; - let confidence: f64 = row.get(5)?; - let meta_str: String = row.get(6)?; - let created_str: String = row.get(7)?; - let accessed_str: String = row.get(8)?; - let access_count: i64 = row.get(9)?; - let embedding_bytes: Option> = row.get(10)?; Ok(( - id_str, - agent_str, - content, - source_str, - scope, - confidence, - meta_str, - created_str, - accessed_str, - access_count, - embedding_bytes, + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, f64>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, Option>>(10)?, )) }) .map_err(|e| OpenFangError::Memory(e.to_string()))?; let mut fragments = Vec::new(); for row_result in rows { - let ( - id_str, - agent_str, - content, - source_str, - scope, - confidence, - meta_str, - created_str, - accessed_str, - access_count, - embedding_bytes, - ) = row_result.map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let id = uuid::Uuid::parse_str(&id_str) - .map(MemoryId) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let agent_id = uuid::Uuid::parse_str(&agent_str) - .map(openfang_types::agent::AgentId) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let source: MemorySource = - serde_json::from_str(&source_str).unwrap_or(MemorySource::System); - let metadata: HashMap = - serde_json::from_str(&meta_str).unwrap_or_default(); - let created_at = chrono::DateTime::parse_from_rfc3339(&created_str) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - let accessed_at = chrono::DateTime::parse_from_rfc3339(&accessed_str) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()); - + let (id_str, agent_str, content, source_str, scope, confidence, meta_str, created_str, accessed_str, access_count, embedding_bytes) = + row_result.map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let id = helpers::parse_memory_id(&id_str)?; + let agent_id = helpers::parse_agent_id(&agent_str)?; + let source: MemorySource = helpers::deserialize_source(&source_str); + let metadata: HashMap = helpers::deserialize_metadata(&meta_str); + let created_at = helpers::parse_rfc3339_or_now(&created_str); + let accessed_at = helpers::parse_rfc3339_or_now(&accessed_str); let embedding = embedding_bytes.as_deref().map(embedding_from_bytes); fragments.push(MemoryFragment { - id, - agent_id, - content, - embedding, - metadata, - source, - confidence: confidence as f32, - created_at, - accessed_at, - access_count: access_count as u64, - scope, + id, agent_id, content, embedding, metadata, source, + confidence: confidence as f32, created_at, accessed_at, + access_count: access_count as u64, scope, }); } - // If we have a query embedding, re-rank by cosine similarity + // Brute-force cosine re-ranking when no vec table was available if let Some(qe) = query_embedding { fragments.sort_by(|a, b| { - let sim_a = a - .embedding - .as_deref() - .map(|e| cosine_similarity(qe, e)) - .unwrap_or(-1.0); - let sim_b = b - .embedding - .as_deref() - .map(|e| cosine_similarity(qe, e)) - .unwrap_or(-1.0); - sim_b - .partial_cmp(&sim_a) - .unwrap_or(std::cmp::Ordering::Equal) + let sim_a = a.embedding.as_deref().map(|e| cosine_similarity(qe, e)).unwrap_or(-1.0); + let sim_b = b.embedding.as_deref().map(|e| cosine_similarity(qe, e)).unwrap_or(-1.0); + sim_b.partial_cmp(&sim_a).unwrap_or(std::cmp::Ordering::Equal) }); fragments.truncate(limit); - debug!( - "Vector recall: {} results from {} candidates", - fragments.len(), - fetch_limit - ); } - // Update access counts for returned memories + // Update access counts for frag in &fragments { let _ = conn.execute( "UPDATE memories SET access_count = access_count + 1, accessed_at = ?1 WHERE id = ?2", @@ -378,16 +296,108 @@ impl SemanticStore { Ok(fragments) } - /// Soft-delete a memory fragment. - /// - /// In HTTP mode, logs a warning (memory-api doesn't support delete yet) - /// and performs the soft-delete locally only. - pub fn forget(&self, id: MemoryId) -> OpenFangResult<()> { - #[cfg(feature = "http-memory")] - if self.http_client.is_some() { - warn!(id = %id.0, "forget() not supported via HTTP backend, local-only soft-delete"); + /// sqlite-vec indexed vector search: uses MATCH on the vec0 virtual table, + /// then JOINs back to `memories` for full fragment data. + fn recall_via_vec( + &self, + conn: &Connection, + query_embedding: &[f32], + limit: usize, + filter: &Option, + ) -> OpenFangResult> { + let query_bytes = embedding_to_bytes(query_embedding); + // Fetch more than needed to allow post-filtering + let fetch_limit = limit * 5; + + let mut stmt = conn + .prepare( + "SELECT m.id, m.agent_id, m.content, m.source, m.scope, m.confidence, + m.metadata, m.created_at, m.accessed_at, m.access_count, m.embedding, + v.distance + FROM memories_vec v + JOIN memories m ON m.id = v.memory_id + WHERE v.embedding MATCH ?1 + AND m.deleted = 0 + ORDER BY v.distance + LIMIT ?2", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = stmt + .query_map(rusqlite::params![query_bytes, fetch_limit as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, f64>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, Option>>(10)?, + row.get::<_, f64>(11)?, + )) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut fragments = Vec::new(); + for row_result in rows { + let (id_str, agent_str, content, source_str, scope, confidence, meta_str, + created_str, accessed_str, access_count, embedding_bytes, _distance) = + row_result.map_err(|e| OpenFangError::Memory(e.to_string()))?; + + // Post-filter by agent, scope, confidence, source + if let Some(ref f) = filter { + if let Some(filter_agent) = f.agent_id { + if agent_str != filter_agent.0.to_string() { + continue; + } + } + if let Some(ref filter_scope) = f.scope { + if &scope != filter_scope { + continue; + } + } + if let Some(min_conf) = f.min_confidence { + if (confidence as f32) < min_conf { + continue; + } + } + if let Some(ref filter_source) = f.source { + let source_str_expected = helpers::serialize_source(filter_source).unwrap_or_default(); + if source_str != source_str_expected { + continue; + } + } + } + + let id = helpers::parse_memory_id(&id_str)?; + let agent_id = helpers::parse_agent_id(&agent_str)?; + let source: MemorySource = helpers::deserialize_source(&source_str); + let metadata: HashMap = helpers::deserialize_metadata(&meta_str); + let created_at = helpers::parse_rfc3339_or_now(&created_str); + let accessed_at = helpers::parse_rfc3339_or_now(&accessed_str); + let embedding = embedding_bytes.as_deref().map(embedding_from_bytes); + + fragments.push(MemoryFragment { + id, agent_id, content, embedding, metadata, source, + confidence: confidence as f32, created_at, accessed_at, + access_count: access_count as u64, scope, + }); + + if fragments.len() >= limit { + break; + } } + debug!("sqlite-vec recall: {} results", fragments.len()); + Ok(fragments) + } + + /// Soft-delete a memory fragment. + pub fn forget(&self, id: MemoryId) -> OpenFangResult<()> { let conn = self .conn .lock() @@ -397,6 +407,13 @@ impl SemanticStore { rusqlite::params![id.0.to_string()], ) .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + // Also remove from vec table + let _ = conn.execute( + "DELETE FROM memories_vec WHERE memory_id = ?1", + rusqlite::params![id.0.to_string()], + ); + Ok(()) } @@ -412,58 +429,48 @@ impl SemanticStore { rusqlite::params![bytes, id.0.to_string()], ) .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + // Also upsert into vec table + let _ = Self::ensure_vec_table(&conn, embedding.len()); + let _ = conn.execute( + "INSERT OR REPLACE INTO memories_vec (memory_id, embedding) VALUES (?1, ?2)", + rusqlite::params![id.0.to_string(), bytes], + ); + Ok(()) } - /// HTTP implementation of recall — routes to memory-api POST /memory/search. - /// - /// Maps memory-api search results to `MemoryFragment` structs. Fields not - /// available from the HTTP API (agent_id, embedding, access_count) use defaults. - #[cfg(feature = "http-memory")] - fn recall_via_http( +} + +impl SemanticBackend for SemanticStore { + fn remember( + &self, + agent_id: AgentId, + content: &str, + source: MemorySource, + scope: &str, + metadata: HashMap, + embedding: Option<&[f32]>, + ) -> OpenFangResult { + SemanticStore::remember_with_embedding(self, agent_id, content, source, scope, metadata, embedding) + } + + fn recall( &self, - client: &MemoryApiClient, query: &str, limit: usize, - filter: &Option, + filter: Option, + query_embedding: Option<&[f32]>, ) -> OpenFangResult> { - let category = filter.as_ref().and_then(|f| f.scope.as_deref()); - - let results = client - .search(query, limit, category) - .map_err(|e| OpenFangError::Memory(format!("HTTP search failed: {e}")))?; - - let fragments: Vec = results - .into_iter() - .map(|r| { - let created_at = r - .created_at - .map(|ms| { - chrono::DateTime::from_timestamp_millis(ms as i64).unwrap_or_else(Utc::now) - }) - .unwrap_or_else(Utc::now); - - MemoryFragment { - id: MemoryId::new(), - agent_id: filter.as_ref().and_then(|f| f.agent_id).unwrap_or_default(), - content: r.content, - embedding: None, - metadata: HashMap::new(), - source: MemorySource::System, - confidence: r.score as f32, - created_at, - accessed_at: Utc::now(), - access_count: 0, - scope: r.category.unwrap_or_else(|| "general".to_string()), - } - }) - .collect(); + SemanticStore::recall_with_embedding(self, query, limit, filter, query_embedding) + } - debug!( - count = fragments.len(), - "Recalled memories via HTTP backend" - ); - Ok(fragments) + fn forget(&self, id: MemoryId) -> OpenFangResult<()> { + SemanticStore::forget(self, id) + } + + fn update_embedding(&self, id: MemoryId, embedding: &[f32]) -> OpenFangResult<()> { + SemanticStore::update_embedding(self, id, embedding) } } @@ -508,7 +515,7 @@ fn embedding_from_bytes(bytes: &[u8]) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::migration::run_migrations; + use crate::sqlite::migration::run_migrations; fn setup() -> SemanticStore { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/openfang-memory/src/sqlite/session.rs b/crates/openfang-memory/src/sqlite/session.rs new file mode 100644 index 0000000000..b241c7df4c --- /dev/null +++ b/crates/openfang-memory/src/sqlite/session.rs @@ -0,0 +1,672 @@ +//! SQLite backend for session management — load/save conversation history. + +use crate::backends::SessionBackend; +use crate::helpers; +use crate::session::{CanonicalSession, Session}; +use chrono::Utc; +use openfang_types::agent::{AgentId, SessionId}; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::message::{ContentBlock, Message, MessageContent, Role}; +use rusqlite::Connection; +use std::io::Write; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// Session store backed by SQLite. +#[derive(Clone)] +pub struct SessionStore { + conn: Arc>, +} + +impl SessionStore { + /// Create a new session store wrapping the given connection. + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// Load a session from the database. + pub fn get_session(&self, session_id: SessionId) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let mut stmt = conn + .prepare("SELECT agent_id, messages, context_window_tokens, label FROM sessions WHERE id = ?1") + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let result = stmt.query_row(rusqlite::params![session_id.0.to_string()], |row| { + let agent_str: String = row.get(0)?; + let messages_blob: Vec = row.get(1)?; + let tokens: i64 = row.get(2)?; + let label: Option = row.get(3).unwrap_or(None); + Ok((agent_str, messages_blob, tokens, label)) + }); + + match result { + Ok((agent_str, messages_blob, tokens, label)) => { + let agent_id = helpers::parse_agent_id(&agent_str)?; + let messages: Vec = helpers::deserialize_messages(&messages_blob)?; + Ok(Some(Session { + id: session_id, + agent_id, + messages, + context_window_tokens: tokens as u64, + label, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(OpenFangError::Memory(e.to_string())), + } + } + + /// Save a session to the database. + pub fn save_session(&self, session: &Session) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let messages_blob = helpers::serialize_messages_named(&session.messages)?; + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO sessions (id, agent_id, messages, context_window_tokens, label, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) + ON CONFLICT(id) DO UPDATE SET messages = ?3, context_window_tokens = ?4, label = ?5, updated_at = ?6", + rusqlite::params![ + session.id.0.to_string(), + session.agent_id.0.to_string(), + messages_blob, + session.context_window_tokens as i64, + session.label.as_deref(), + now, + ], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// Delete a session from the database. + pub fn delete_session(&self, session_id: SessionId) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + conn.execute( + "DELETE FROM sessions WHERE id = ?1", + rusqlite::params![session_id.0.to_string()], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// Delete all sessions belonging to an agent. + pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + conn.execute( + "DELETE FROM sessions WHERE agent_id = ?1", + rusqlite::params![agent_id.0.to_string()], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// Delete the canonical (cross-channel) session for an agent. + pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + conn.execute( + "DELETE FROM canonical_sessions WHERE agent_id = ?1", + rusqlite::params![agent_id.0.to_string()], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// List all sessions with metadata (session_id, agent_id, message_count, created_at). + pub fn list_sessions(&self) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let mut stmt = conn + .prepare( + "SELECT id, agent_id, messages, created_at, label FROM sessions ORDER BY created_at DESC", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + let session_id: String = row.get(0)?; + let agent_id: String = row.get(1)?; + let messages_blob: Vec = row.get(2)?; + let created_at: String = row.get(3)?; + let label: Option = row.get(4)?; + // Deserialize just to count messages + let msg_count = helpers::deserialize_messages_lossy(&messages_blob).len(); + Ok(serde_json::json!({ + "session_id": session_id, + "agent_id": agent_id, + "message_count": msg_count, + "created_at": created_at, + "label": label, + })) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(sessions) + } + + /// Set the label on an existing session. + pub fn set_session_label( + &self, + session_id: SessionId, + label: Option<&str>, + ) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + conn.execute( + "UPDATE sessions SET label = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![label, Utc::now().to_rfc3339(), session_id.0.to_string()], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// Find a session by label for a given agent. + pub fn find_session_by_label( + &self, + agent_id: AgentId, + label: &str, + ) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let mut stmt = conn + .prepare( + "SELECT id, messages, context_window_tokens, label FROM sessions \ + WHERE agent_id = ?1 AND label = ?2 LIMIT 1", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let result = stmt.query_row(rusqlite::params![agent_id.0.to_string(), label], |row| { + let id_str: String = row.get(0)?; + let messages_blob: Vec = row.get(1)?; + let tokens: i64 = row.get(2)?; + let lbl: Option = row.get(3).unwrap_or(None); + Ok((id_str, messages_blob, tokens, lbl)) + }); + + match result { + Ok((id_str, messages_blob, tokens, lbl)) => { + let session_id = helpers::parse_session_id(&id_str)?; + let messages: Vec = helpers::deserialize_messages(&messages_blob)?; + Ok(Some(Session { + id: session_id, + agent_id, + messages, + context_window_tokens: tokens as u64, + label: lbl, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(OpenFangError::Memory(e.to_string())), + } + } +} + +impl SessionStore { + /// List all sessions for a specific agent. + pub fn list_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let mut stmt = conn + .prepare( + "SELECT id, messages, created_at, label FROM sessions WHERE agent_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = stmt + .query_map(rusqlite::params![agent_id.0.to_string()], |row| { + let session_id: String = row.get(0)?; + let messages_blob: Vec = row.get(1)?; + let created_at: String = row.get(2)?; + let label: Option = row.get(3)?; + let msg_count = helpers::deserialize_messages_lossy(&messages_blob).len(); + Ok(serde_json::json!({ + "session_id": session_id, + "message_count": msg_count, + "created_at": created_at, + "label": label, + })) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(sessions) + } + +} + +impl SessionStore { + /// Load the canonical session for an agent, creating one if it doesn't exist. + pub fn load_canonical(&self, agent_id: AgentId) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let mut stmt = conn + .prepare( + "SELECT messages, compaction_cursor, compacted_summary, updated_at \ + FROM canonical_sessions WHERE agent_id = ?1", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let result = stmt.query_row(rusqlite::params![agent_id.0.to_string()], |row| { + let messages_blob: Vec = row.get(0)?; + let cursor: i64 = row.get(1)?; + let summary: Option = row.get(2)?; + let updated_at: String = row.get(3)?; + Ok((messages_blob, cursor, summary, updated_at)) + }); + + match result { + Ok((messages_blob, cursor, summary, updated_at)) => { + let messages: Vec = helpers::deserialize_messages(&messages_blob)?; + Ok(CanonicalSession { + agent_id, + messages, + compaction_cursor: cursor as usize, + compacted_summary: summary, + updated_at, + }) + } + Err(rusqlite::Error::QueryReturnedNoRows) => { + let now = Utc::now().to_rfc3339(); + Ok(CanonicalSession { + agent_id, + messages: Vec::new(), + compaction_cursor: 0, + compacted_summary: None, + updated_at: now, + }) + } + Err(e) => Err(OpenFangError::Memory(e.to_string())), + } + } + + /// Persist a canonical session to SQLite. + pub fn save_canonical(&self, canonical: &CanonicalSession) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let messages_blob = helpers::serialize_messages(&canonical.messages)?; + conn.execute( + "INSERT INTO canonical_sessions (agent_id, messages, compaction_cursor, compacted_summary, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(agent_id) DO UPDATE SET messages = ?2, compaction_cursor = ?3, compacted_summary = ?4, updated_at = ?5", + rusqlite::params![ + canonical.agent_id.0.to_string(), + messages_blob, + canonical.compaction_cursor as i64, + canonical.compacted_summary, + canonical.updated_at, + ], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } +} + +/// A single JSONL line in the session mirror file. +#[derive(serde::Serialize)] +struct JsonlLine { + timestamp: String, + role: String, + content: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + tool_use: Option, +} + +impl SessionStore { + /// Write a human-readable JSONL mirror of a session to disk. + /// + /// Best-effort: errors are returned but should be logged and never + /// affect the primary SQLite store. + pub fn write_jsonl_mirror( + &self, + session: &Session, + sessions_dir: &Path, + ) -> Result<(), std::io::Error> { + std::fs::create_dir_all(sessions_dir)?; + let path = sessions_dir.join(format!("{}.jsonl", session.id.0)); + let mut file = std::fs::File::create(&path)?; + let now = Utc::now().to_rfc3339(); + + for msg in &session.messages { + let role_str = match msg.role { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + }; + + let mut text_parts: Vec = Vec::new(); + let mut tool_parts: Vec = Vec::new(); + + match &msg.content { + MessageContent::Text(t) => { + text_parts.push(t.clone()); + } + MessageContent::Blocks(blocks) => { + for block in blocks { + match block { + ContentBlock::Text { text, .. } => { + text_parts.push(text.clone()); + } + ContentBlock::ToolUse { + id, name, input, .. + } => { + tool_parts.push(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + })); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name: _, + content, + is_error, + } => { + tool_parts.push(serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + "is_error": is_error, + })); + } + ContentBlock::Image { media_type, .. } => { + text_parts.push(format!("[image: {media_type}]")); + } + ContentBlock::Thinking { thinking, .. } => { + text_parts.push(format!( + "[thinking: {}]", + openfang_types::truncate_str(thinking, 200) + )); + } + ContentBlock::Unknown => {} + } + } + } + } + + let line = JsonlLine { + timestamp: now.clone(), + role: role_str.to_string(), + content: serde_json::Value::String(text_parts.join("\n")), + tool_use: if tool_parts.is_empty() { + None + } else { + Some(serde_json::Value::Array(tool_parts)) + }, + }; + + serde_json::to_writer(&mut file, &line).map_err(std::io::Error::other)?; + file.write_all(b"\n")?; + } + + Ok(()) + } +} + +impl SessionBackend for SessionStore { + fn get_session(&self, id: SessionId) -> OpenFangResult> { + SessionStore::get_session(self, id) + } + fn save_session(&self, session: &Session) -> OpenFangResult<()> { + SessionStore::save_session(self, session) + } + fn delete_session(&self, id: SessionId) -> OpenFangResult<()> { + SessionStore::delete_session(self, id) + } + fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> { + SessionStore::delete_agent_sessions(self, agent_id) + } + fn list_sessions(&self) -> OpenFangResult> { + SessionStore::list_sessions(self) + } + fn list_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult> { + SessionStore::list_agent_sessions(self, agent_id) + } + fn set_session_label(&self, id: SessionId, label: Option<&str>) -> OpenFangResult<()> { + SessionStore::set_session_label(self, id, label) + } + fn find_session_by_label( + &self, + agent_id: AgentId, + label: &str, + ) -> OpenFangResult> { + SessionStore::find_session_by_label(self, agent_id, label) + } + fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> { + SessionStore::delete_canonical_session(self, agent_id) + } + fn load_canonical(&self, agent_id: AgentId) -> OpenFangResult { + SessionStore::load_canonical(self, agent_id) + } + fn save_canonical(&self, canonical: &CanonicalSession) -> OpenFangResult<()> { + SessionStore::save_canonical(self, canonical) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migration::run_migrations; + + fn setup() -> SessionStore { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + SessionStore::new(Arc::new(Mutex::new(conn))) + } + + #[test] + fn test_create_and_load_session() { + let store = setup(); + let agent_id = AgentId::new(); + let session = store.create_session(agent_id).unwrap(); + + let loaded = store.get_session(session.id).unwrap().unwrap(); + assert_eq!(loaded.agent_id, agent_id); + assert!(loaded.messages.is_empty()); + } + + #[test] + fn test_save_and_load_with_messages() { + let store = setup(); + let agent_id = AgentId::new(); + let mut session = store.create_session(agent_id).unwrap(); + session.messages.push(Message::user("Hello")); + session.messages.push(Message::assistant("Hi there!")); + store.save_session(&session).unwrap(); + + let loaded = store.get_session(session.id).unwrap().unwrap(); + assert_eq!(loaded.messages.len(), 2); + } + + #[test] + fn test_get_missing_session() { + let store = setup(); + let result = store.get_session(SessionId::new()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_delete_session() { + let store = setup(); + let agent_id = AgentId::new(); + let session = store.create_session(agent_id).unwrap(); + let sid = session.id; + assert!(store.get_session(sid).unwrap().is_some()); + store.delete_session(sid).unwrap(); + assert!(store.get_session(sid).unwrap().is_none()); + } + + #[test] + fn test_delete_agent_sessions() { + let store = setup(); + let agent_id = AgentId::new(); + let s1 = store.create_session(agent_id).unwrap(); + let s2 = store.create_session(agent_id).unwrap(); + assert!(store.get_session(s1.id).unwrap().is_some()); + assert!(store.get_session(s2.id).unwrap().is_some()); + store.delete_agent_sessions(agent_id).unwrap(); + assert!(store.get_session(s1.id).unwrap().is_none()); + assert!(store.get_session(s2.id).unwrap().is_none()); + } + + #[test] + fn test_canonical_load_creates_empty() { + let store = setup(); + let agent_id = AgentId::new(); + let canonical = store.load_canonical(agent_id).unwrap(); + assert_eq!(canonical.agent_id, agent_id); + assert!(canonical.messages.is_empty()); + assert!(canonical.compacted_summary.is_none()); + assert_eq!(canonical.compaction_cursor, 0); + } + + #[test] + fn test_canonical_append_and_load() { + let store = setup(); + let agent_id = AgentId::new(); + + // Append from "Telegram" + let msgs1 = vec![ + Message::user("Hello from Telegram"), + Message::assistant("Hi! I'm your agent."), + ]; + store.append_canonical(agent_id, &msgs1, None).unwrap(); + + // Append from "Discord" + let msgs2 = vec![ + Message::user("Now I'm on Discord"), + Message::assistant("I remember you from Telegram!"), + ]; + let canonical = store.append_canonical(agent_id, &msgs2, None).unwrap(); + + // Should have all 4 messages + assert_eq!(canonical.messages.len(), 4); + } + + #[test] + fn test_canonical_context_window() { + let store = setup(); + let agent_id = AgentId::new(); + + // Add 10 messages + let msgs: Vec = (0..10) + .map(|i| Message::user(format!("Message {i}"))) + .collect(); + store.append_canonical(agent_id, &msgs, None).unwrap(); + + // Request window of 3 + let (summary, recent) = store.canonical_context(agent_id, Some(3)).unwrap(); + assert_eq!(recent.len(), 3); + assert!(summary.is_none()); // No compaction yet + } + + #[test] + fn test_canonical_compaction() { + let store = setup(); + let agent_id = AgentId::new(); + + // Add 120 messages (over the default 100 threshold) + let msgs: Vec = (0..120) + .map(|i| Message::user(format!("Message number {i} with some content"))) + .collect(); + let canonical = store.append_canonical(agent_id, &msgs, Some(100)).unwrap(); + + // After compaction: should keep DEFAULT_CANONICAL_WINDOW (50) messages + assert!(canonical.messages.len() <= 60); // some tolerance + assert!(canonical.compacted_summary.is_some()); + } + + #[test] + fn test_canonical_cross_channel_roundtrip() { + let store = setup(); + let agent_id = AgentId::new(); + + // Channel 1: user tells agent their name + store + .append_canonical( + agent_id, + &[ + Message::user("My name is Jaber"), + Message::assistant("Nice to meet you, Jaber!"), + ], + None, + ) + .unwrap(); + + // Channel 2: different channel queries same agent + let (summary, recent) = store.canonical_context(agent_id, None).unwrap(); + // The agent should have context about "Jaber" from the previous channel + let all_text: String = recent.iter().map(|m| m.content.text_content()).collect(); + assert!(all_text.contains("Jaber")); + assert!(summary.is_none()); // Only 2 messages, no compaction + } + + #[test] + fn test_jsonl_mirror_write() { + let store = setup(); + let agent_id = AgentId::new(); + let mut session = store.create_session(agent_id).unwrap(); + session + .messages + .push(openfang_types::message::Message::user("Hello")); + session + .messages + .push(openfang_types::message::Message::assistant("Hi there!")); + store.save_session(&session).unwrap(); + + let dir = tempfile::TempDir::new().unwrap(); + let sessions_dir = dir.path().join("sessions"); + store.write_jsonl_mirror(&session, &sessions_dir).unwrap(); + + let jsonl_path = sessions_dir.join(format!("{}.jsonl", session.id.0)); + assert!(jsonl_path.exists()); + + let content = std::fs::read_to_string(&jsonl_path).unwrap(); + let lines: Vec<&str> = content.trim().split('\n').collect(); + assert_eq!(lines.len(), 2); + + // Verify first line is user message + let line1: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(line1["role"], "user"); + assert_eq!(line1["content"], "Hello"); + + // Verify second line is assistant message + let line2: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(line2["role"], "assistant"); + assert_eq!(line2["content"], "Hi there!"); + assert!(line2.get("tool_use").is_none()); + } +} diff --git a/crates/openfang-memory/src/structured.rs b/crates/openfang-memory/src/sqlite/structured.rs similarity index 90% rename from crates/openfang-memory/src/structured.rs rename to crates/openfang-memory/src/sqlite/structured.rs index fb7b45f5e3..a71229bd09 100644 --- a/crates/openfang-memory/src/structured.rs +++ b/crates/openfang-memory/src/sqlite/structured.rs @@ -1,8 +1,10 @@ -//! SQLite structured store for key-value pairs and agent persistence. +//! SQLite backend for structured key-value pairs and agent persistence. +use crate::helpers; use chrono::Utc; use openfang_types::agent::{AgentEntry, AgentId}; use openfang_types::error::{OpenFangError, OpenFangResult}; +use openfang_types::storage::StructuredBackend; use rusqlite::Connection; use std::sync::{Arc, Mutex}; @@ -118,8 +120,7 @@ impl StructuredStore { .map_err(|e| OpenFangError::Internal(e.to_string()))?; // Use named-field encoding so new fields with #[serde(default)] are // handled gracefully when the struct evolves between versions. - let manifest_blob = rmp_serde::to_vec_named(&entry.manifest) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let manifest_blob = helpers::serialize_manifest(&entry.manifest)?; let state_str = serde_json::to_string(&entry.state) .map_err(|e| OpenFangError::Serialization(e.to_string()))?; let now = Utc::now().to_rfc3339(); @@ -203,16 +204,14 @@ impl StructuredStore { match result { Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) => { - let manifest = rmp_serde::from_slice(&manifest_blob) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let manifest = helpers::deserialize_manifest(&manifest_blob)?; let state = serde_json::from_str(&state_str) .map_err(|e| OpenFangError::Serialization(e.to_string()))?; let created_at = chrono::DateTime::parse_from_rfc3339(&created_str) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); let session_id = session_id_str - .and_then(|s| uuid::Uuid::parse_str(&s).ok()) - .map(openfang_types::agent::SessionId) + .and_then(|s| helpers::parse_session_id(&s).ok()) .unwrap_or_else(openfang_types::agent::SessionId::new); let identity = identity_str .and_then(|s| serde_json::from_str(&s).ok()) @@ -329,8 +328,7 @@ impl StructuredStore { continue; } - let agent_id = match uuid::Uuid::parse_str(&id_str).map(openfang_types::agent::AgentId) - { + let agent_id = match helpers::parse_agent_id(&id_str) { Ok(id) => id, Err(e) => { tracing::warn!(agent = %name, "Skipping agent with bad UUID '{id_str}': {e}"); @@ -338,7 +336,7 @@ impl StructuredStore { } }; - let manifest: openfang_types::agent::AgentManifest = match rmp_serde::from_slice( + let manifest: openfang_types::agent::AgentManifest = match helpers::deserialize_manifest( &manifest_blob, ) { Ok(m) => m, @@ -353,8 +351,7 @@ impl StructuredStore { // Auto-repair: re-serialize with current schema and queue for update. // This upgrades the stored blob so future boots don't hit lenient paths. - let new_blob = rmp_serde::to_vec_named(&manifest) - .map_err(|e| OpenFangError::Serialization(e.to_string()))?; + let new_blob = helpers::serialize_manifest(&manifest)?; if new_blob != manifest_blob { tracing::info!( agent = %name, id = %id_str, @@ -374,8 +371,7 @@ impl StructuredStore { .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); let session_id = session_id_str - .and_then(|s| uuid::Uuid::parse_str(&s).ok()) - .map(openfang_types::agent::SessionId) + .and_then(|s| helpers::parse_session_id(&s).ok()) .unwrap_or_else(openfang_types::agent::SessionId::new); let identity = identity_str @@ -439,10 +435,40 @@ impl StructuredStore { } } +impl StructuredBackend for StructuredStore { + fn get(&self, agent_id: AgentId, key: &str) -> OpenFangResult> { + StructuredStore::get(self, agent_id, key) + } + fn set(&self, agent_id: AgentId, key: &str, value: serde_json::Value) -> OpenFangResult<()> { + StructuredStore::set(self, agent_id, key, value) + } + fn delete(&self, agent_id: AgentId, key: &str) -> OpenFangResult<()> { + StructuredStore::delete(self, agent_id, key) + } + fn list_kv(&self, agent_id: AgentId) -> OpenFangResult> { + StructuredStore::list_kv(self, agent_id) + } + fn save_agent(&self, entry: &AgentEntry) -> OpenFangResult<()> { + StructuredStore::save_agent(self, entry) + } + fn load_agent(&self, agent_id: AgentId) -> OpenFangResult> { + StructuredStore::load_agent(self, agent_id) + } + fn remove_agent(&self, agent_id: AgentId) -> OpenFangResult<()> { + StructuredStore::remove_agent(self, agent_id) + } + fn load_all_agents(&self) -> OpenFangResult> { + StructuredStore::load_all_agents(self) + } + fn list_agents(&self) -> OpenFangResult> { + StructuredStore::list_agents(self) + } +} + #[cfg(test)] mod tests { use super::*; - use crate::migration::run_migrations; + use crate::sqlite::migration::run_migrations; fn setup() -> StructuredStore { let conn = Connection::open_in_memory().unwrap(); diff --git a/crates/openfang-memory/src/sqlite/task_queue.rs b/crates/openfang-memory/src/sqlite/task_queue.rs new file mode 100644 index 0000000000..813c7ae503 --- /dev/null +++ b/crates/openfang-memory/src/sqlite/task_queue.rs @@ -0,0 +1,164 @@ +//! SQLite implementation of the task queue store. + +use crate::backends::TaskQueueBackend; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; + +/// Task-queue store backed by SQLite. +#[derive(Clone)] +pub struct SqliteTaskQueueStore { + conn: Arc>, +} + +impl SqliteTaskQueueStore { + /// Create a new task-queue store wrapping the given connection. + pub fn new(conn: Arc>) -> Self { + Self { conn } + } +} + +impl TaskQueueBackend for SqliteTaskQueueStore { + fn task_post( + &self, + title: &str, + description: &str, + assigned_to: &str, + created_by: &str, + ) -> OpenFangResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + let db = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + db.execute( + "INSERT INTO task_queue (id, agent_id, task_type, payload, status, priority, created_at, title, description, assigned_to, created_by) + VALUES (?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![id, created_by, title, b"", now, title, description, assigned_to, created_by], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(id) + } + + fn task_claim(&self, agent_id: &str) -> OpenFangResult> { + let db = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let mut stmt = db + .prepare( + "SELECT id, title, description, assigned_to, created_by, created_at + FROM task_queue + WHERE status = 'pending' AND (assigned_to = ?1 OR assigned_to = '') + ORDER BY priority DESC, created_at ASC + LIMIT 1", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let result = stmt.query_row(rusqlite::params![agent_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }); + + match result { + Ok((id, title, description, assigned, created_by, created_at)) => { + // Update status to in_progress + db.execute( + "UPDATE task_queue SET status = 'in_progress', assigned_to = ?2 WHERE id = ?1", + rusqlite::params![id, agent_id], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let display_assigned = if assigned.is_empty() { + agent_id.to_string() + } else { + assigned + }; + + Ok(Some(serde_json::json!({ + "id": id, + "title": title, + "description": description, + "status": "in_progress", + "assigned_to": display_assigned, + "created_by": created_by, + "created_at": created_at, + }))) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(OpenFangError::Memory(e.to_string())), + } + } + + fn task_complete(&self, task_id: &str, result: &str) -> OpenFangResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + let db = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let rows = db + .execute( + "UPDATE task_queue SET status = 'completed', result = ?2, completed_at = ?3 WHERE id = ?1", + rusqlite::params![task_id, result, now], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + if rows == 0 { + return Err(OpenFangError::Internal(format!( + "Task not found: {task_id}" + ))); + } + Ok(()) + } + + fn task_list(&self, status: Option<&str>) -> OpenFangResult> { + let db = self + .conn + .lock() + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let (sql, params): (&str, Vec>) = match status { + Some(s) => ( + "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result FROM task_queue WHERE status = ?1 ORDER BY created_at DESC", + vec![Box::new(s.to_string())], + ), + None => ( + "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result FROM task_queue ORDER BY created_at DESC", + vec![], + ), + }; + + let mut stmt = db + .prepare(sql) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "title": row.get::<_, String>(1).unwrap_or_default(), + "description": row.get::<_, String>(2).unwrap_or_default(), + "status": row.get::<_, String>(3)?, + "assigned_to": row.get::<_, String>(4).unwrap_or_default(), + "created_by": row.get::<_, String>(5).unwrap_or_default(), + "created_at": row.get::<_, String>(6).unwrap_or_default(), + "completed_at": row.get::<_, Option>(7).unwrap_or(None), + "result": row.get::<_, Option>(8).unwrap_or(None), + })) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut tasks = Vec::new(); + for row in rows { + tasks.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(tasks) + } +} diff --git a/crates/openfang-memory/src/sqlite/usage.rs b/crates/openfang-memory/src/sqlite/usage.rs new file mode 100644 index 0000000000..e17a94f634 --- /dev/null +++ b/crates/openfang-memory/src/sqlite/usage.rs @@ -0,0 +1,521 @@ +//! SQLite backend for usage tracking — records LLM usage events for cost monitoring. + +use crate::backends::UsageBackend; +use crate::usage::{DailyBreakdown, ModelUsage, UsageRecord, UsageSummary}; +use chrono::Utc; +use openfang_types::agent::AgentId; +use openfang_types::error::{OpenFangError, OpenFangResult}; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; + +/// Usage store backed by SQLite. +#[derive(Clone)] +pub struct UsageStore { + conn: Arc>, +} + +impl UsageStore { + /// Create a new usage store wrapping the given connection. + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + /// Record a usage event. + pub fn record(&self, record: &UsageRecord) -> OpenFangResult<()> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let id = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO usage_events (id, agent_id, timestamp, model, input_tokens, output_tokens, cost_usd, tool_calls) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + id, + record.agent_id.0.to_string(), + now, + record.model, + record.input_tokens as i64, + record.output_tokens as i64, + record.cost_usd, + record.tool_calls as i64, + ], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(()) + } + + /// Query total cost in the last hour for an agent. + pub fn query_hourly(&self, agent_id: AgentId) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE agent_id = ?1 AND timestamp > datetime('now', '-1 hour')", + rusqlite::params![agent_id.0.to_string()], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Query total cost today for an agent. + pub fn query_daily(&self, agent_id: AgentId) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE agent_id = ?1 AND timestamp > datetime('now', 'start of day')", + rusqlite::params![agent_id.0.to_string()], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Query total cost in the current calendar month for an agent. + pub fn query_monthly(&self, agent_id: AgentId) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE agent_id = ?1 AND timestamp > datetime('now', 'start of month')", + rusqlite::params![agent_id.0.to_string()], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Query total cost across all agents for the current hour. + pub fn query_global_hourly(&self) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE timestamp > datetime('now', '-1 hour')", + [], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Query total cost across all agents for the current calendar month. + pub fn query_global_monthly(&self) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE timestamp > datetime('now', 'start of month')", + [], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Query usage summary, optionally filtered by agent. + pub fn query_summary(&self, agent_id: Option) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + + let (sql, params): (&str, Vec>) = match agent_id { + Some(aid) => ( + "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost_usd), 0.0), COUNT(*), COALESCE(SUM(tool_calls), 0) + FROM usage_events WHERE agent_id = ?1", + vec![Box::new(aid.0.to_string())], + ), + None => ( + "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cost_usd), 0.0), COUNT(*), COALESCE(SUM(tool_calls), 0) + FROM usage_events", + vec![], + ), + }; + + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + + let summary = conn + .query_row(sql, params_refs.as_slice(), |row| { + Ok(UsageSummary { + total_input_tokens: row.get::<_, i64>(0)? as u64, + total_output_tokens: row.get::<_, i64>(1)? as u64, + total_cost_usd: row.get(2)?, + call_count: row.get::<_, i64>(3)? as u64, + total_tool_calls: row.get::<_, i64>(4)? as u64, + }) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + Ok(summary) + } + + /// Query usage grouped by model. + pub fn query_by_model(&self) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + + let mut stmt = conn + .prepare( + "SELECT model, COALESCE(SUM(cost_usd), 0.0), COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), COUNT(*) + FROM usage_events GROUP BY model ORDER BY SUM(cost_usd) DESC", + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(ModelUsage { + model: row.get(0)?, + total_cost_usd: row.get(1)?, + total_input_tokens: row.get::<_, i64>(2)? as u64, + total_output_tokens: row.get::<_, i64>(3)? as u64, + call_count: row.get::<_, i64>(4)? as u64, + }) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut results = Vec::new(); + for row in rows { + results.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(results) + } + + /// Query daily usage breakdown for the last N days. + pub fn query_daily_breakdown(&self, days: u32) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + + let mut stmt = conn + .prepare(&format!( + "SELECT date(timestamp) as day, + COALESCE(SUM(cost_usd), 0.0), + COALESCE(SUM(input_tokens) + SUM(output_tokens), 0), + COUNT(*) + FROM usage_events + WHERE timestamp > datetime('now', '-{days} days') + GROUP BY day + ORDER BY day ASC" + )) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(DailyBreakdown { + date: row.get(0)?, + cost_usd: row.get(1)?, + tokens: row.get::<_, i64>(2)? as u64, + calls: row.get::<_, i64>(3)? as u64, + }) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + + let mut results = Vec::new(); + for row in rows { + results.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); + } + Ok(results) + } + + /// Query the timestamp of the earliest usage event. + pub fn query_first_event_date(&self) -> OpenFangResult> { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let result: Option = conn + .query_row("SELECT MIN(timestamp) FROM usage_events", [], |row| { + row.get(0) + }) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(result) + } + + /// Query today's total cost across all agents. + pub fn query_today_cost(&self) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let cost: f64 = conn + .query_row( + "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events + WHERE timestamp > datetime('now', 'start of day')", + [], + |row| row.get(0), + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(cost) + } + + /// Delete usage events older than the given number of days. + pub fn cleanup_old(&self, days: u32) -> OpenFangResult { + let conn = self + .conn + .lock() + .map_err(|e| OpenFangError::Internal(e.to_string()))?; + let deleted = conn + .execute( + &format!( + "DELETE FROM usage_events WHERE timestamp < datetime('now', '-{days} days')" + ), + [], + ) + .map_err(|e| OpenFangError::Memory(e.to_string()))?; + Ok(deleted) + } +} + +impl UsageBackend for UsageStore { + fn record(&self, record: &UsageRecord) -> OpenFangResult<()> { + UsageStore::record(self, record) + } + fn query_hourly(&self, agent_id: AgentId) -> OpenFangResult { + UsageStore::query_hourly(self, agent_id) + } + fn query_daily(&self, agent_id: AgentId) -> OpenFangResult { + UsageStore::query_daily(self, agent_id) + } + fn query_monthly(&self, agent_id: AgentId) -> OpenFangResult { + UsageStore::query_monthly(self, agent_id) + } + fn query_global_hourly(&self) -> OpenFangResult { + UsageStore::query_global_hourly(self) + } + fn query_global_monthly(&self) -> OpenFangResult { + UsageStore::query_global_monthly(self) + } + fn query_summary(&self, agent_id: Option) -> OpenFangResult { + UsageStore::query_summary(self, agent_id) + } + fn query_by_model(&self) -> OpenFangResult> { + UsageStore::query_by_model(self) + } + fn query_daily_breakdown(&self, days: u32) -> OpenFangResult> { + UsageStore::query_daily_breakdown(self, days) + } + fn query_first_event_date(&self) -> OpenFangResult> { + UsageStore::query_first_event_date(self) + } + fn query_today_cost(&self) -> OpenFangResult { + UsageStore::query_today_cost(self) + } + fn cleanup_old(&self, days: u32) -> OpenFangResult { + UsageStore::cleanup_old(self, days) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migration::run_migrations; + + fn setup() -> UsageStore { + let conn = Connection::open_in_memory().unwrap(); + run_migrations(&conn).unwrap(); + UsageStore::new(Arc::new(Mutex::new(conn))) + } + + #[test] + fn test_record_and_query_summary() { + let store = setup(); + let agent_id = AgentId::new(); + + store + .record(&UsageRecord { + agent_id, + model: "claude-haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.001, + tool_calls: 2, + }) + .unwrap(); + + store + .record(&UsageRecord { + agent_id, + model: "claude-sonnet".to_string(), + input_tokens: 500, + output_tokens: 200, + cost_usd: 0.01, + tool_calls: 1, + }) + .unwrap(); + + let summary = store.query_summary(Some(agent_id)).unwrap(); + assert_eq!(summary.call_count, 2); + assert_eq!(summary.total_input_tokens, 600); + assert_eq!(summary.total_output_tokens, 250); + assert!((summary.total_cost_usd - 0.011).abs() < 0.0001); + assert_eq!(summary.total_tool_calls, 3); + } + + #[test] + fn test_query_summary_all_agents() { + let store = setup(); + let a1 = AgentId::new(); + let a2 = AgentId::new(); + + store + .record(&UsageRecord { + agent_id: a1, + model: "haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.001, + tool_calls: 0, + }) + .unwrap(); + + store + .record(&UsageRecord { + agent_id: a2, + model: "sonnet".to_string(), + input_tokens: 200, + output_tokens: 100, + cost_usd: 0.005, + tool_calls: 1, + }) + .unwrap(); + + let summary = store.query_summary(None).unwrap(); + assert_eq!(summary.call_count, 2); + assert_eq!(summary.total_input_tokens, 300); + } + + #[test] + fn test_query_by_model() { + let store = setup(); + let agent_id = AgentId::new(); + + for _ in 0..3 { + store + .record(&UsageRecord { + agent_id, + model: "haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.001, + tool_calls: 0, + }) + .unwrap(); + } + + store + .record(&UsageRecord { + agent_id, + model: "sonnet".to_string(), + input_tokens: 500, + output_tokens: 200, + cost_usd: 0.01, + tool_calls: 1, + }) + .unwrap(); + + let by_model = store.query_by_model().unwrap(); + assert_eq!(by_model.len(), 2); + // sonnet should be first (highest cost) + assert_eq!(by_model[0].model, "sonnet"); + assert_eq!(by_model[1].model, "haiku"); + assert_eq!(by_model[1].call_count, 3); + } + + #[test] + fn test_query_hourly() { + let store = setup(); + let agent_id = AgentId::new(); + + store + .record(&UsageRecord { + agent_id, + model: "haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.05, + tool_calls: 0, + }) + .unwrap(); + + let hourly = store.query_hourly(agent_id).unwrap(); + assert!((hourly - 0.05).abs() < 0.001); + } + + #[test] + fn test_query_daily() { + let store = setup(); + let agent_id = AgentId::new(); + + store + .record(&UsageRecord { + agent_id, + model: "haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.123, + tool_calls: 0, + }) + .unwrap(); + + let daily = store.query_daily(agent_id).unwrap(); + assert!((daily - 0.123).abs() < 0.001); + } + + #[test] + fn test_cleanup_old() { + let store = setup(); + let agent_id = AgentId::new(); + + store + .record(&UsageRecord { + agent_id, + model: "haiku".to_string(), + input_tokens: 100, + output_tokens: 50, + cost_usd: 0.001, + tool_calls: 0, + }) + .unwrap(); + + // Cleanup events older than 1 day should not remove today's events + let deleted = store.cleanup_old(1).unwrap(); + assert_eq!(deleted, 0); + + let summary = store.query_summary(None).unwrap(); + assert_eq!(summary.call_count, 1); + } + + #[test] + fn test_empty_summary() { + let store = setup(); + let summary = store.query_summary(None).unwrap(); + assert_eq!(summary.call_count, 0); + assert_eq!(summary.total_cost_usd, 0.0); + } +} diff --git a/crates/openfang-memory/src/substrate.rs b/crates/openfang-memory/src/substrate.rs index 03ecd78cfc..46271119f4 100644 --- a/crates/openfang-memory/src/substrate.rs +++ b/crates/openfang-memory/src/substrate.rs @@ -2,135 +2,483 @@ //! //! Composes the structured store, semantic store, knowledge store, //! session store, and consolidation engine behind a single async API. +//! +//! Storage is selected via two independent, typed config fields: +//! [`openfang_types::config::MemoryBackendKind`] (structured/session/usage/etc.) +//! and [`openfang_types::config::SemanticBackendKind`] (vector search). They +//! may be mixed freely — e.g. `backend = Sqlite` with `semantic_backend = Qdrant` +//! is a valid combination. +//! +//! Any Postgres-backed choice requires [`MemorySubstrate::open_async`]; the +//! synchronous [`MemorySubstrate::open`] handles SQLite-only paths and errors +//! otherwise. +//! +//! Initialization is fail-fast: if a requested backend cannot be reached +//! (Qdrant down, HTTP gateway health check fails, Postgres unreachable) the +//! daemon exits with a readable error. There is no silent SQLite fallback. +//! +//! This file is 100% backend-agnostic — zero rusqlite imports. -use crate::consolidation::ConsolidationEngine; -use crate::knowledge::KnowledgeStore; -use crate::migration::run_migrations; -use crate::semantic::SemanticStore; -use crate::session::{Session, SessionStore}; -use crate::structured::StructuredStore; -use crate::usage::UsageStore; +use crate::backends::{ + AuditBackend, ConsolidationBackend, PairedDevicesBackend, SessionBackend, TaskQueueBackend, + UsageBackend, +}; +use crate::session::Session; use async_trait::async_trait; use openfang_types::agent::{AgentEntry, AgentId, SessionId}; -use openfang_types::config::MemoryConfig; +use openfang_types::config::{MemoryBackendKind, MemoryConfig, SemanticBackendKind}; +use openfang_types::embedding::EmbeddingDriver; use openfang_types::error::{OpenFangError, OpenFangResult}; use openfang_types::memory::{ ConsolidationReport, Entity, ExportFormat, GraphMatch, GraphPattern, ImportReport, Memory, MemoryFilter, MemoryFragment, MemoryId, MemorySource, Relation, }; -use rusqlite::Connection; +use openfang_types::storage::{KnowledgeBackend, SemanticBackend, StructuredBackend}; use std::collections::HashMap; use std::path::Path; -use std::sync::{Arc, Mutex}; -use tracing::{info, warn}; +use std::sync::Arc; +#[cfg(any(feature = "postgres", feature = "qdrant", feature = "http-memory"))] +use tracing::info; +use tracing::warn; /// The unified memory substrate. Implements the `Memory` trait by delegating -/// to specialized stores backed by a shared SQLite connection. +/// to specialized stores backed by pluggable backends. +/// +/// When an `EmbeddingDriver` is set, `remember()` and `recall()` automatically +/// generate embeddings — callers don't need to handle embedding themselves. pub struct MemorySubstrate { - conn: Arc>, - structured: StructuredStore, - semantic: SemanticStore, - knowledge: KnowledgeStore, - sessions: SessionStore, - consolidation: ConsolidationEngine, - usage: UsageStore, + structured: Arc, + semantic: Arc, + knowledge: Arc, + sessions: Arc, + usage: Arc, + paired_devices: Arc, + task_queue: Arc, + consolidation: Option>, + audit: Option>, + embedding_driver: Option>, +} + +/// Small wrapper so `select_semantic` can take a single shape regardless of +/// whether the `postgres` feature is compiled in. When the feature is off the +/// type has no variants that actually carry a pool. +struct SemanticPgPool { + #[cfg(feature = "postgres")] + pool: Option, +} + +impl SemanticPgPool { + fn none() -> Self { + Self { + #[cfg(feature = "postgres")] + pool: None, + } + } + + #[cfg(feature = "postgres")] + fn with_pool(pool: deadpool_postgres::Pool) -> Self { + Self { pool: Some(pool) } + } + + #[cfg(feature = "postgres")] + fn as_pool(&self) -> Option<&deadpool_postgres::Pool> { + self.pool.as_ref() + } } impl MemorySubstrate { - /// Open or create a memory substrate at the given database path. + /// Resolve the effective semantic backend — the explicit value or, when + /// unset, the one implied by the structured `backend` choice. + fn effective_semantic(config: &MemoryConfig) -> SemanticBackendKind { + config.semantic_backend.unwrap_or(match config.backend { + MemoryBackendKind::Sqlite => SemanticBackendKind::Sqlite, + MemoryBackendKind::Postgres => SemanticBackendKind::Postgres, + }) + } + + /// Open or create a memory substrate synchronously. /// - /// When `memory_config.backend == "http"` and `http_url`/`http_token_env` are set, - /// the semantic store routes `remember`/`recall` to the memory-api gateway. - /// All other stores (KV, knowledge graph, sessions) remain local SQLite. + /// Supports only `backend = Sqlite` with a SQLite semantic backend. Any + /// other combination (Postgres for either store, or a Qdrant/HTTP semantic + /// backend whose probe is async) returns an error and the caller should + /// use [`Self::open_async`] instead. pub fn open( db_path: &Path, decay_rate: f32, memory_config: &MemoryConfig, ) -> OpenFangResult { - let conn = Connection::open(db_path).map_err(|e| OpenFangError::Memory(e.to_string()))?; - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?; - let shared = Arc::new(Mutex::new(conn)); + if let MemoryBackendKind::Postgres = memory_config.backend { + return Err(OpenFangError::Memory(format!( + "memory backend={} requires MemorySubstrate::open_async (async init)", + MemoryBackendKind::Postgres + ))); + } - let semantic = Self::create_semantic_store(Arc::clone(&shared), memory_config); + match Self::effective_semantic(memory_config) { + SemanticBackendKind::Sqlite => { + Self::open_sqlite_sync_sqlite_semantic(db_path, decay_rate) + } + kind @ (SemanticBackendKind::Postgres + | SemanticBackendKind::Qdrant + | SemanticBackendKind::Http) => Err(OpenFangError::Memory(format!( + "semantic_backend={kind} requires MemorySubstrate::open_async (async init)" + ))), + } + } + /// Open or create a memory substrate. Async-aware: this is the correct + /// constructor to use from within a running tokio runtime, and is required + /// when the backend selection involves PostgreSQL, Qdrant, or the HTTP + /// memory gateway (all of which fail-fast on init with a live probe). + pub async fn open_async( + db_path: &Path, + decay_rate: f32, + memory_config: &MemoryConfig, + ) -> OpenFangResult { + match memory_config.backend { + MemoryBackendKind::Sqlite => { + // Structured = SQLite. Decide if we still need a PG pool for + // the semantic arm. + let pg_pool = match Self::effective_semantic(memory_config) { + SemanticBackendKind::Postgres => { + #[cfg(feature = "postgres")] + { + let pool = Self::init_postgres_pool(memory_config).await?; + SemanticPgPool::with_pool(pool) + } + #[cfg(not(feature = "postgres"))] + { + return Err(OpenFangError::Config(format!( + "semantic_backend = {} requires the 'postgres' cargo feature", + SemanticBackendKind::Postgres + ))); + } + } + _ => SemanticPgPool::none(), + }; + Self::open_sqlite_inner(db_path, decay_rate, memory_config, pg_pool).await + } + MemoryBackendKind::Postgres => { + #[cfg(feature = "postgres")] + { + Self::open_postgres_async(memory_config, decay_rate).await + } + #[cfg(not(feature = "postgres"))] + { + Err(OpenFangError::Config(format!( + "backend = {} requires the 'postgres' cargo feature", + MemoryBackendKind::Postgres + ))) + } + } + } + } + + /// Sync-only path: SQLite structured + SQLite semantic. No network probes, + /// so no runtime needed. + fn open_sqlite_sync_sqlite_semantic( + db_path: &Path, + decay_rate: f32, + ) -> OpenFangResult { + let backend = crate::sqlite::SqliteBackend::open(db_path)?; Ok(Self { - conn: Arc::clone(&shared), - structured: StructuredStore::new(Arc::clone(&shared)), + structured: Arc::new(backend.structured()), + semantic: Arc::new(backend.semantic()), + knowledge: Arc::new(backend.knowledge()), + sessions: Arc::new(backend.session()), + usage: Arc::new(backend.usage()), + paired_devices: Arc::new(backend.paired_devices()), + task_queue: Arc::new(backend.task_queue()), + consolidation: Some(Arc::new(backend.consolidation(decay_rate))), + audit: Some(Arc::new(backend.audit())), + embedding_driver: None, + }) + } + + /// Open a SQLite-backed memory substrate (async — semantic selection may + /// perform a Qdrant or HTTP probe). + /// + /// `pg_pool` is an optional Postgres pool used only when + /// `semantic_backend = Postgres` is paired with the SQLite backend. + async fn open_sqlite_inner( + db_path: &Path, + decay_rate: f32, + memory_config: &MemoryConfig, + pg_pool: SemanticPgPool, + ) -> OpenFangResult { + let backend = crate::sqlite::SqliteBackend::open(db_path)?; + let default_semantic: Arc = Arc::new(backend.semantic()); + let semantic = Self::select_semantic(memory_config, default_semantic, &pg_pool).await?; + + Ok(Self { + structured: Arc::new(backend.structured()), semantic, - knowledge: KnowledgeStore::new(Arc::clone(&shared)), - sessions: SessionStore::new(Arc::clone(&shared)), - usage: UsageStore::new(Arc::clone(&shared)), - consolidation: ConsolidationEngine::new(shared, decay_rate), + knowledge: Arc::new(backend.knowledge()), + sessions: Arc::new(backend.session()), + usage: Arc::new(backend.usage()), + paired_devices: Arc::new(backend.paired_devices()), + task_queue: Arc::new(backend.task_queue()), + consolidation: Some(Arc::new(backend.consolidation(decay_rate))), + audit: Some(Arc::new(backend.audit())), + embedding_driver: None, }) } - /// Create the semantic store, optionally with HTTP backend. - fn create_semantic_store( - conn: Arc>, + /// Create a Postgres pool, validate pool-size bounds, probe it with a + /// `SELECT 1`, and run migrations. Shared between `backend = Postgres` + /// and `semantic_backend = Postgres` on top of SQLite. + #[cfg(feature = "postgres")] + async fn init_postgres_pool( memory_config: &MemoryConfig, - ) -> SemanticStore { - #[cfg(feature = "http-memory")] - if memory_config.backend == "http" { - if let (Some(url), Some(token_env)) = - (&memory_config.http_url, &memory_config.http_token_env) - { - match crate::http_client::MemoryApiClient::new(url, token_env) { - Ok(client) => { - // Best-effort health check on startup - match client.health_check() { - Ok(()) => info!(url = %url, "HTTP memory backend connected"), - Err(e) => { - warn!(url = %url, error = %e, "HTTP memory backend health check failed, will retry on use") - } - } - return SemanticStore::new_with_http(conn, client); - } - Err(e) => { - warn!(error = %e, "Failed to create HTTP memory client, falling back to SQLite"); - } + ) -> OpenFangResult { + let pg_url = memory_config.postgres.postgres_url.as_deref().ok_or_else(|| { + OpenFangError::Memory( + "postgres backend requires postgres_url in config".to_string(), + ) + })?; + + // Fail-fast pool-size validation before we try to open any sockets. + if memory_config.postgres_pool_size == 0 { + return Err(OpenFangError::Config( + "postgres_pool_size must be > 0".to_string(), + )); + } + if memory_config.postgres_pool_size > 1000 { + return Err(OpenFangError::Config(format!( + "postgres_pool_size = {} exceeds the safety cap of 1000", + memory_config.postgres_pool_size + ))); + } + + let pool = crate::postgres::create_pool(pg_url, memory_config.postgres_pool_size)?; + + // Probe the pool once. Fail loudly if we cannot check out a client or + // run a trivial query — matches the deadpool-postgres fail-fast pattern. + { + let client = pool.get().await.map_err(|e| { + OpenFangError::Memory(format!( + "{} backend failed to initialize at {pg_url}: {e}", + MemoryBackendKind::Postgres + )) + })?; + client.simple_query("SELECT 1").await.map_err(|e| { + OpenFangError::Memory(format!( + "{} backend failed to initialize at {pg_url}: {e}", + MemoryBackendKind::Postgres + )) + })?; + } + + crate::postgres::run_migrations(&pool).await?; + info!( + backend = %MemoryBackendKind::Postgres, + url = %pg_url, + pool_size = memory_config.postgres_pool_size, + "memory backend connected" + ); + Ok(pool) + } + + /// Async PostgreSQL backend initialization — safe from inside a tokio runtime. + #[cfg(feature = "postgres")] + async fn open_postgres_async( + memory_config: &MemoryConfig, + decay_rate: f32, + ) -> OpenFangResult { + let pool = Self::init_postgres_pool(memory_config).await?; + let backend = crate::postgres::PgBackend::new(pool.clone()); + let default_semantic: Arc = Arc::new(backend.semantic()); + let semantic = Self::select_semantic( + memory_config, + default_semantic, + &SemanticPgPool::with_pool(pool), + ) + .await?; + + Ok(Self { + structured: Arc::new(backend.structured()), + semantic, + knowledge: Arc::new(backend.knowledge()), + sessions: Arc::new(backend.session()), + usage: Arc::new(backend.usage()), + paired_devices: Arc::new(backend.paired_devices()), + task_queue: Arc::new(backend.task_queue()), + consolidation: Some(Arc::new(backend.consolidation().with_decay_rate(decay_rate))), + audit: Some(Arc::new(backend.audit())), + embedding_driver: None, + }) + } + + /// Select the semantic backend based on the typed + /// [`SemanticBackendKind`] enum. Falls back to a + /// `backend`-implied choice when `semantic_backend` is `None`. + /// + /// Initialization is strict: Qdrant and HTTP backends probe their remotes + /// and return `Err` on failure rather than silently falling back to SQLite. + async fn select_semantic( + config: &MemoryConfig, + default: Arc, + pg_pool: &SemanticPgPool, + ) -> OpenFangResult> { + // Silence unused-variable warnings when feature flags are off. + let _ = (&default, pg_pool); + + match Self::effective_semantic(config) { + SemanticBackendKind::Sqlite => Ok(default), + + SemanticBackendKind::Postgres => { + #[cfg(feature = "postgres")] + { + let pool = pg_pool.as_pool().ok_or_else(|| { + OpenFangError::Config(format!( + "semantic_backend = {pg} but no Postgres pool is available; \ + use MemorySubstrate::open_async or set backend = {pg}", + pg = SemanticBackendKind::Postgres + )) + })?; + info!(backend = %SemanticBackendKind::Postgres, "semantic backend connected (pgvector)"); + Ok(Arc::new(crate::postgres::PostgresSemanticStore::new( + pool.clone(), + ))) + } + #[cfg(not(feature = "postgres"))] + { + Err(OpenFangError::Config(format!( + "semantic_backend = {} requires the 'postgres' cargo feature", + SemanticBackendKind::Postgres + ))) } - } else { - warn!("backend=http but http_url/http_token_env not set, falling back to SQLite"); } - } - #[cfg(not(feature = "http-memory"))] - let _ = memory_config; + SemanticBackendKind::Qdrant => { + #[cfg(feature = "qdrant")] + { + let url = config + .qdrant + .qdrant_url + .as_deref() + .unwrap_or("http://localhost:6334"); + let api_key = config + .qdrant + .qdrant_api_key_env + .as_deref() + .and_then(|env_var| std::env::var(env_var).ok()); + let store = crate::qdrant::QdrantSemanticStore::new( + url, + api_key.as_deref(), + &config.qdrant.qdrant_collection, + ) + .await + .map_err(|e| { + OpenFangError::Memory(format!( + "{} backend failed to initialize at {url}: {e}", + SemanticBackendKind::Qdrant + )) + })?; + info!( + backend = %SemanticBackendKind::Qdrant, + url = %url, + collection = %config.qdrant.qdrant_collection, + "semantic backend connected" + ); + Ok(Arc::new(store)) + } + #[cfg(not(feature = "qdrant"))] + { + Err(OpenFangError::Config(format!( + "semantic_backend = {} requires the 'qdrant' cargo feature", + SemanticBackendKind::Qdrant + ))) + } + } - SemanticStore::new(conn) + SemanticBackendKind::Http => { + #[cfg(feature = "http-memory")] + { + let (url, token_env) = match (&config.http.http_url, &config.http.http_token_env) { + (Some(u), Some(t)) => (u, t), + _ => { + return Err(OpenFangError::Config(format!( + "semantic_backend = {} requires http_url and \ + http_token_env in config", + SemanticBackendKind::Http + ))); + } + }; + let client = + crate::http::MemoryApiClient::new(url, token_env).map_err(|e| { + OpenFangError::Memory(format!( + "{} memory-api backend failed to initialize at {url}: {e}", + SemanticBackendKind::Http + )) + })?; + client.health_check().map_err(|e| { + OpenFangError::Memory(format!( + "{} memory-api backend failed health check at {url}: {e}", + SemanticBackendKind::Http + )) + })?; + info!(backend = %SemanticBackendKind::Http, url = %url, "semantic backend connected"); + Ok(Arc::new(crate::http::HttpSemanticStore::new(client, default))) + } + #[cfg(not(feature = "http-memory"))] + { + Err(OpenFangError::Config(format!( + "semantic_backend = {} requires the 'http-memory' cargo feature", + SemanticBackendKind::Http + ))) + } + } + } } /// Create an in-memory substrate (for testing). Always uses SQLite backend. pub fn open_in_memory(decay_rate: f32) -> OpenFangResult { - let conn = - Connection::open_in_memory().map_err(|e| OpenFangError::Memory(e.to_string()))?; - run_migrations(&conn).map_err(|e| OpenFangError::Memory(e.to_string()))?; - let shared = Arc::new(Mutex::new(conn)); + let backend = crate::sqlite::SqliteBackend::open_in_memory()?; Ok(Self { - conn: Arc::clone(&shared), - structured: StructuredStore::new(Arc::clone(&shared)), - semantic: SemanticStore::new(Arc::clone(&shared)), - knowledge: KnowledgeStore::new(Arc::clone(&shared)), - sessions: SessionStore::new(Arc::clone(&shared)), - usage: UsageStore::new(Arc::clone(&shared)), - consolidation: ConsolidationEngine::new(shared, decay_rate), + structured: Arc::new(backend.structured()), + semantic: Arc::new(backend.semantic()), + knowledge: Arc::new(backend.knowledge()), + sessions: Arc::new(backend.session()), + usage: Arc::new(backend.usage()), + paired_devices: Arc::new(backend.paired_devices()), + task_queue: Arc::new(backend.task_queue()), + consolidation: Some(Arc::new(backend.consolidation(decay_rate))), + audit: Some(Arc::new(backend.audit())), + embedding_driver: None, }) } - /// Get a reference to the usage store. - pub fn usage(&self) -> &UsageStore { - &self.usage + /// Set the embedding driver for automatic embedding generation. + pub fn set_embedding_driver(&mut self, driver: Option>) { + self.embedding_driver = driver; } - /// Get the shared database connection (for constructing stores from outside). - pub fn usage_conn(&self) -> Arc> { - Arc::clone(&self.conn) + // ----------------------------------------------------------------- + // Usage accessors + // ----------------------------------------------------------------- + + /// Get a reference to the usage backend. + pub fn usage(&self) -> &dyn UsageBackend { + self.usage.as_ref() } + /// Get a shared-ownership handle to the usage backend. + pub fn usage_arc(&self) -> Arc { + Arc::clone(&self.usage) + } + + /// Get the audit backend, if available. + pub fn audit(&self) -> Option> { + self.audit.clone() + } + + // ----------------------------------------------------------------- + // Agent persistence + // ----------------------------------------------------------------- + /// Save an agent entry to persistent storage. pub fn save_agent(&self, entry: &AgentEntry) -> OpenFangResult<()> { self.structured.save_agent(entry) @@ -158,6 +506,10 @@ impl MemorySubstrate { self.structured.list_agents() } + // ----------------------------------------------------------------- + // Structured KV store + // ----------------------------------------------------------------- + /// Synchronous get from the structured store (for kernel handle use). pub fn structured_get( &self, @@ -187,6 +539,10 @@ impl MemorySubstrate { self.structured.set(agent_id, key, value) } + // ----------------------------------------------------------------- + // Session operations + // ----------------------------------------------------------------- + /// Get a session by ID. pub fn get_session(&self, session_id: SessionId) -> OpenFangResult> { self.sessions.get_session(session_id) @@ -197,10 +553,10 @@ impl MemorySubstrate { self.sessions.save_session(session) } - /// Save a session asynchronously — runs the SQLite write in a blocking + /// Save a session asynchronously — runs the write in a blocking /// thread so the tokio runtime stays responsive. pub async fn save_session_async(&self, session: &Session) -> OpenFangResult<()> { - let sessions = self.sessions.clone(); + let sessions = Arc::clone(&self.sessions); let session = session.clone(); tokio::task::spawn_blocking(move || sessions.save_session(&session)) .await @@ -290,18 +646,6 @@ impl MemorySubstrate { .store_llm_summary(agent_id, summary, kept_messages) } - /// Write a human-readable JSONL mirror of a session to disk. - /// - /// Best-effort — errors are returned but should be logged, - /// never affecting the primary SQLite store. - pub fn write_jsonl_mirror( - &self, - session: &Session, - sessions_dir: &Path, - ) -> Result<(), std::io::Error> { - self.sessions.write_jsonl_mirror(session, sessions_dir) - } - /// Append messages to the agent's canonical session for cross-channel persistence. pub fn append_canonical( &self, @@ -320,30 +664,7 @@ impl MemorySubstrate { /// Load all paired devices from the database. pub fn load_paired_devices(&self) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let mut stmt = conn.prepare( - "SELECT device_id, display_name, platform, paired_at, last_seen, push_token FROM paired_devices" - ).map_err(|e| OpenFangError::Memory(e.to_string()))?; - let rows = stmt - .query_map([], |row| { - Ok(serde_json::json!({ - "device_id": row.get::<_, String>(0)?, - "display_name": row.get::<_, String>(1)?, - "platform": row.get::<_, String>(2)?, - "paired_at": row.get::<_, String>(3)?, - "last_seen": row.get::<_, String>(4)?, - "push_token": row.get::<_, Option>(5)?, - })) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - let mut devices = Vec::new(); - for row in rows { - devices.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(devices) + self.paired_devices.load_paired_devices() } /// Save a paired device to the database (insert or replace). @@ -356,29 +677,13 @@ impl MemorySubstrate { last_seen: &str, push_token: Option<&str>, ) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - conn.execute( - "INSERT OR REPLACE INTO paired_devices (device_id, display_name, platform, paired_at, last_seen, push_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![device_id, display_name, platform, paired_at, last_seen, push_token], - ).map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) + self.paired_devices + .save_paired_device(device_id, display_name, platform, paired_at, last_seen, push_token) } /// Remove a paired device from the database. pub fn remove_paired_device(&self, device_id: &str) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - conn.execute( - "DELETE FROM paired_devices WHERE device_id = ?1", - rusqlite::params![device_id], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) + self.paired_devices.remove_paired_device(device_id) } // ----------------------------------------------------------------- @@ -396,7 +701,7 @@ impl MemorySubstrate { embedding: Option<&[f32]>, ) -> OpenFangResult { self.semantic - .remember_with_embedding(agent_id, content, source, scope, metadata, embedding) + .remember(agent_id, content, source, scope, metadata, embedding) } /// Recall memories using vector similarity when a query embedding is provided. @@ -408,7 +713,7 @@ impl MemorySubstrate { query_embedding: Option<&[f32]>, ) -> OpenFangResult> { self.semantic - .recall_with_embedding(query, limit, filter, query_embedding) + .recall(query, limit, filter, query_embedding) } /// Update the embedding for an existing memory. @@ -424,11 +729,11 @@ impl MemorySubstrate { filter: Option, query_embedding: Option<&[f32]>, ) -> OpenFangResult> { - let store = self.semantic.clone(); + let store = Arc::clone(&self.semantic); let query = query.to_string(); let embedding_owned = query_embedding.map(|e| e.to_vec()); tokio::task::spawn_blocking(move || { - store.recall_with_embedding(&query, limit, filter, embedding_owned.as_deref()) + store.recall(&query, limit, filter, embedding_owned.as_deref()) }) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? @@ -444,12 +749,12 @@ impl MemorySubstrate { metadata: HashMap, embedding: Option<&[f32]>, ) -> OpenFangResult { - let store = self.semantic.clone(); + let store = Arc::clone(&self.semantic); let content = content.to_string(); let scope = scope.to_string(); let embedding_owned = embedding.map(|e| e.to_vec()); tokio::task::spawn_blocking(move || { - store.remember_with_embedding( + store.remember( agent_id, &content, source, @@ -474,152 +779,69 @@ impl MemorySubstrate { assigned_to: Option<&str>, created_by: Option<&str>, ) -> OpenFangResult { - let conn = Arc::clone(&self.conn); + let tq = Arc::clone(&self.task_queue); let title = title.to_string(); let description = description.to_string(); let assigned_to = assigned_to.unwrap_or("").to_string(); let created_by = created_by.unwrap_or("").to_string(); - tokio::task::spawn_blocking(move || { - let id = uuid::Uuid::new_v4().to_string(); - let now = chrono::Utc::now().to_rfc3339(); - let db = conn.lock().map_err(|e| OpenFangError::Internal(e.to_string()))?; - db.execute( - "INSERT INTO task_queue (id, agent_id, task_type, payload, status, priority, created_at, title, description, assigned_to, created_by) - VALUES (?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?7, ?8, ?9)", - rusqlite::params![id, &created_by, &title, b"", now, title, description, assigned_to, created_by], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(id) - }) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + tokio::task::spawn_blocking(move || tq.task_post(&title, &description, &assigned_to, &created_by)) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? } /// Claim the next pending task (optionally for a specific assignee). Returns task JSON or None. pub async fn task_claim(&self, agent_id: &str) -> OpenFangResult> { - let conn = Arc::clone(&self.conn); + let tq = Arc::clone(&self.task_queue); let agent_id = agent_id.to_string(); - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| OpenFangError::Internal(e.to_string()))?; - // Find first pending task assigned to this agent, or any unassigned pending task - let mut stmt = db.prepare( - "SELECT id, title, description, assigned_to, created_by, created_at - FROM task_queue - WHERE status = 'pending' AND (assigned_to = ?1 OR assigned_to = '') - ORDER BY priority DESC, created_at ASC - LIMIT 1" - ).map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let result = stmt.query_row(rusqlite::params![agent_id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - )) - }); - - match result { - Ok((id, title, description, assigned, created_by, created_at)) => { - // Update status to in_progress - db.execute( - "UPDATE task_queue SET status = 'in_progress', assigned_to = ?2 WHERE id = ?1", - rusqlite::params![id, agent_id], - ).map_err(|e| OpenFangError::Memory(e.to_string()))?; - - Ok(Some(serde_json::json!({ - "id": id, - "title": title, - "description": description, - "status": "in_progress", - "assigned_to": if assigned.is_empty() { &agent_id } else { &assigned }, - "created_by": created_by, - "created_at": created_at, - }))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(OpenFangError::Memory(e.to_string())), - } - }) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + tokio::task::spawn_blocking(move || tq.task_claim(&agent_id)) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? } /// Mark a task as completed with a result string. pub async fn task_complete(&self, task_id: &str, result: &str) -> OpenFangResult<()> { - let conn = Arc::clone(&self.conn); + let tq = Arc::clone(&self.task_queue); let task_id = task_id.to_string(); let result = result.to_string(); - tokio::task::spawn_blocking(move || { - let now = chrono::Utc::now().to_rfc3339(); - let db = conn.lock().map_err(|e| OpenFangError::Internal(e.to_string()))?; - let rows = db.execute( - "UPDATE task_queue SET status = 'completed', result = ?2, completed_at = ?3 WHERE id = ?1", - rusqlite::params![task_id, result, now], - ).map_err(|e| OpenFangError::Memory(e.to_string()))?; - if rows == 0 { - return Err(OpenFangError::Internal(format!("Task not found: {task_id}"))); - } - Ok(()) - }) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + tokio::task::spawn_blocking(move || tq.task_complete(&task_id, &result)) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? } /// List tasks, optionally filtered by status. pub async fn task_list(&self, status: Option<&str>) -> OpenFangResult> { - let conn = Arc::clone(&self.conn); + let tq = Arc::clone(&self.task_queue); let status = status.map(|s| s.to_string()); - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| OpenFangError::Internal(e.to_string()))?; - let (sql, params): (&str, Vec>) = match &status { - Some(s) => ( - "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result FROM task_queue WHERE status = ?1 ORDER BY created_at DESC", - vec![Box::new(s.clone())], - ), - None => ( - "SELECT id, title, description, status, assigned_to, created_by, created_at, completed_at, result FROM task_queue ORDER BY created_at DESC", - vec![], - ), - }; + tokio::task::spawn_blocking(move || tq.task_list(status.as_deref())) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? + } - let mut stmt = db.prepare(sql).map_err(|e| OpenFangError::Memory(e.to_string()))?; - let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let rows = stmt.query_map(params_refs.as_slice(), |row| { - Ok(serde_json::json!({ - "id": row.get::<_, String>(0)?, - "title": row.get::<_, String>(1).unwrap_or_default(), - "description": row.get::<_, String>(2).unwrap_or_default(), - "status": row.get::<_, String>(3)?, - "assigned_to": row.get::<_, String>(4).unwrap_or_default(), - "created_by": row.get::<_, String>(5).unwrap_or_default(), - "created_at": row.get::<_, String>(6).unwrap_or_default(), - "completed_at": row.get::<_, Option>(7).unwrap_or(None), - "result": row.get::<_, Option>(8).unwrap_or(None), - })) - }).map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let mut tasks = Vec::new(); - for row in rows { - tasks.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(tasks) - }) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + // ----------------------------------------------------------------- + // JSONL mirror + // ----------------------------------------------------------------- + + /// Write a human-readable JSONL mirror of a session to disk. + /// + /// Best-effort — errors are returned but should be logged, + /// never affecting the primary store. + pub fn write_jsonl_mirror( + &self, + session: &Session, + sessions_dir: &Path, + ) -> Result<(), std::io::Error> { + crate::jsonl::write_session_mirror(session, sessions_dir) } } #[async_trait] impl Memory for MemorySubstrate { async fn get(&self, agent_id: AgentId, key: &str) -> OpenFangResult> { - let store = self.structured.clone(); + let store = Arc::clone(&self.structured); let key = key.to_string(); tokio::task::spawn_blocking(move || store.get(agent_id, &key)) .await @@ -632,7 +854,7 @@ impl Memory for MemorySubstrate { key: &str, value: serde_json::Value, ) -> OpenFangResult<()> { - let store = self.structured.clone(); + let store = Arc::clone(&self.structured); let key = key.to_string(); tokio::task::spawn_blocking(move || store.set(agent_id, &key, value)) .await @@ -640,7 +862,7 @@ impl Memory for MemorySubstrate { } async fn delete(&self, agent_id: AgentId, key: &str) -> OpenFangResult<()> { - let store = self.structured.clone(); + let store = Arc::clone(&self.structured); let key = key.to_string(); tokio::task::spawn_blocking(move || store.delete(agent_id, &key)) .await @@ -655,11 +877,31 @@ impl Memory for MemorySubstrate { scope: &str, metadata: HashMap, ) -> OpenFangResult { - let store = self.semantic.clone(); + // Auto-embed if driver is available + let embedding = if let Some(ref driver) = self.embedding_driver { + match driver.embed_one(content).await { + Ok(vec) => Some(vec), + Err(e) => { + warn!("Auto-embedding failed, storing without embedding: {e}"); + None + } + } + } else { + None + }; + + let store = Arc::clone(&self.semantic); let content = content.to_string(); let scope = scope.to_string(); tokio::task::spawn_blocking(move || { - store.remember(agent_id, &content, source, &scope, metadata) + store.remember( + agent_id, + &content, + source, + &scope, + metadata, + embedding.as_deref(), + ) }) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? @@ -671,50 +913,73 @@ impl Memory for MemorySubstrate { limit: usize, filter: Option, ) -> OpenFangResult> { - let store = self.semantic.clone(); + // Auto-embed query if driver is available + let query_embedding = if let Some(ref driver) = self.embedding_driver { + match driver.embed_one(query).await { + Ok(vec) => Some(vec), + Err(e) => { + warn!("Auto-embedding for recall failed, using text fallback: {e}"); + None + } + } + } else { + None + }; + + let store = Arc::clone(&self.semantic); let query = query.to_string(); - tokio::task::spawn_blocking(move || store.recall(&query, limit, filter)) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + tokio::task::spawn_blocking(move || { + store.recall(&query, limit, filter, query_embedding.as_deref()) + }) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? } async fn forget(&self, id: MemoryId) -> OpenFangResult<()> { - let store = self.semantic.clone(); + let store = Arc::clone(&self.semantic); tokio::task::spawn_blocking(move || store.forget(id)) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? } async fn add_entity(&self, entity: Entity) -> OpenFangResult { - let store = self.knowledge.clone(); + let store = Arc::clone(&self.knowledge); tokio::task::spawn_blocking(move || store.add_entity(entity)) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? } async fn add_relation(&self, relation: Relation) -> OpenFangResult { - let store = self.knowledge.clone(); + let store = Arc::clone(&self.knowledge); tokio::task::spawn_blocking(move || store.add_relation(relation)) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? } async fn query_graph(&self, pattern: GraphPattern) -> OpenFangResult> { - let store = self.knowledge.clone(); + let store = Arc::clone(&self.knowledge); tokio::task::spawn_blocking(move || store.query_graph(pattern)) .await .map_err(|e| OpenFangError::Internal(e.to_string()))? } async fn consolidate(&self) -> OpenFangResult { - let engine = self.consolidation.clone(); - tokio::task::spawn_blocking(move || engine.consolidate()) - .await - .map_err(|e| OpenFangError::Internal(e.to_string()))? + if let Some(ref engine) = self.consolidation { + let engine = Arc::clone(engine); + tokio::task::spawn_blocking(move || engine.consolidate()) + .await + .map_err(|e| OpenFangError::Internal(e.to_string()))? + } else { + // Non-SQLite backends: consolidation not yet implemented + Ok(ConsolidationReport { + memories_decayed: 0, + memories_merged: 0, + duration_ms: 0, + }) + } } - async fn export(&self, format: ExportFormat) -> OpenFangResult> { - let _ = format; + async fn export(&self, _format: ExportFormat) -> OpenFangResult> { Ok(Vec::new()) } @@ -732,6 +997,118 @@ impl Memory for MemorySubstrate { mod tests { use super::*; + /// Feature-gated backends must fail-fast with `OpenFangError::Config` + /// when the required cargo feature is not compiled in — never silently + /// degrade to SQLite. + #[cfg(any( + not(feature = "postgres"), + not(feature = "qdrant"), + not(feature = "http-memory"), + ))] + #[tokio::test] + async fn feature_gated_backend_errors_cleanly_when_feature_off() { + let tmpdir = tempfile::tempdir().unwrap(); + let db_path = tmpdir.path().join("test.db"); + + async fn assert_feature_error( + db_path: &Path, + cfg: MemoryConfig, + expected_backend: &str, + ) { + let result = MemorySubstrate::open_async(db_path, 0.1, &cfg).await; + let err = match result { + Ok(_) => panic!( + "expected init to fail without feature for backend={expected_backend}" + ), + Err(e) => e, + }; + match err { + OpenFangError::Config(msg) => { + assert!( + msg.contains(expected_backend), + "message should name the backend {expected_backend:?}: {msg}" + ); + assert!( + msg.contains("feature"), + "message should mention cargo feature: {msg}" + ); + } + other => panic!("expected Config error, got: {other:?}"), + } + } + + #[cfg(not(feature = "qdrant"))] + assert_feature_error( + &db_path, + MemoryConfig { + semantic_backend: Some(SemanticBackendKind::Qdrant), + ..Default::default() + }, + "qdrant", + ) + .await; + + #[cfg(not(feature = "http-memory"))] + assert_feature_error( + &db_path, + MemoryConfig { + semantic_backend: Some(SemanticBackendKind::Http), + ..Default::default() + }, + "http", + ) + .await; + + #[cfg(not(feature = "postgres"))] + assert_feature_error( + &db_path, + MemoryConfig { + backend: MemoryBackendKind::Postgres, + ..Default::default() + }, + "postgres", + ) + .await; + } + + /// `select_semantic` with `semantic_backend = Postgres` but no pool must + /// return `OpenFangError::Config` — it's a caller misuse (missing + /// `open_async` or missing `backend = postgres`), not a runtime memory + /// failure. Locks in the classification fix at substrate.rs line 334. + #[cfg(feature = "postgres")] + #[tokio::test] + async fn postgres_semantic_without_pool_is_config_error() { + let cfg = MemoryConfig { + backend: MemoryBackendKind::Sqlite, + semantic_backend: Some(SemanticBackendKind::Postgres), + ..Default::default() + }; + // Build a throw-away default semantic so the signature is satisfied. + let backend = crate::sqlite::SqliteBackend::open_in_memory().unwrap(); + let default_semantic: Arc = Arc::new(backend.semantic()); + + let result = + MemorySubstrate::select_semantic(&cfg, default_semantic, &SemanticPgPool::none()) + .await; + let err = match result { + Ok(_) => panic!("pool-less Postgres semantic must error"), + Err(e) => e, + }; + match err { + OpenFangError::Config(msg) => { + assert!( + msg.to_lowercase().contains("postgres"), + "message should name postgres: {msg}" + ); + assert!( + msg.contains("open_async") || msg.contains("backend"), + "message should guide the caller: {msg}" + ); + } + other => panic!("expected Config error, got: {other:?}"), + } + } + #[tokio::test] async fn test_substrate_kv() { let substrate = MemorySubstrate::open_in_memory(0.1).unwrap(); diff --git a/crates/openfang-memory/src/usage.rs b/crates/openfang-memory/src/usage.rs index 277fef438d..5d758d12e6 100644 --- a/crates/openfang-memory/src/usage.rs +++ b/crates/openfang-memory/src/usage.rs @@ -1,11 +1,7 @@ -//! Usage tracking store — records LLM usage events for cost monitoring. +//! Usage tracking types. -use chrono::Utc; use openfang_types::agent::AgentId; -use openfang_types::error::{OpenFangError, OpenFangResult}; -use rusqlite::Connection; use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; /// A single usage event recording an LLM call. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -66,476 +62,3 @@ pub struct DailyBreakdown { /// Number of API calls. pub calls: u64, } - -/// Usage store backed by SQLite. -#[derive(Clone)] -pub struct UsageStore { - conn: Arc>, -} - -impl UsageStore { - /// Create a new usage store wrapping the given connection. - pub fn new(conn: Arc>) -> Self { - Self { conn } - } - - /// Record a usage event. - pub fn record(&self, record: &UsageRecord) -> OpenFangResult<()> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now().to_rfc3339(); - conn.execute( - "INSERT INTO usage_events (id, agent_id, timestamp, model, input_tokens, output_tokens, cost_usd, tool_calls) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - id, - record.agent_id.0.to_string(), - now, - record.model, - record.input_tokens as i64, - record.output_tokens as i64, - record.cost_usd, - record.tool_calls as i64, - ], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(()) - } - - /// Query total cost in the last hour for an agent. - pub fn query_hourly(&self, agent_id: AgentId) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE agent_id = ?1 AND timestamp > datetime('now', '-1 hour')", - rusqlite::params![agent_id.0.to_string()], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Query total cost today for an agent. - pub fn query_daily(&self, agent_id: AgentId) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE agent_id = ?1 AND timestamp > datetime('now', 'start of day')", - rusqlite::params![agent_id.0.to_string()], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Query total cost in the current calendar month for an agent. - pub fn query_monthly(&self, agent_id: AgentId) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE agent_id = ?1 AND timestamp > datetime('now', 'start of month')", - rusqlite::params![agent_id.0.to_string()], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Query total cost across all agents for the current hour. - pub fn query_global_hourly(&self) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE timestamp > datetime('now', '-1 hour')", - [], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Query total cost across all agents for the current calendar month. - pub fn query_global_monthly(&self) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE timestamp > datetime('now', 'start of month')", - [], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Query usage summary, optionally filtered by agent. - pub fn query_summary(&self, agent_id: Option) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - - let (sql, params): (&str, Vec>) = match agent_id { - Some(aid) => ( - "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cost_usd), 0.0), COUNT(*), COALESCE(SUM(tool_calls), 0) - FROM usage_events WHERE agent_id = ?1", - vec![Box::new(aid.0.to_string())], - ), - None => ( - "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cost_usd), 0.0), COUNT(*), COALESCE(SUM(tool_calls), 0) - FROM usage_events", - vec![], - ), - }; - - let params_refs: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - - let summary = conn - .query_row(sql, params_refs.as_slice(), |row| { - Ok(UsageSummary { - total_input_tokens: row.get::<_, i64>(0)? as u64, - total_output_tokens: row.get::<_, i64>(1)? as u64, - total_cost_usd: row.get(2)?, - call_count: row.get::<_, i64>(3)? as u64, - total_tool_calls: row.get::<_, i64>(4)? as u64, - }) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - Ok(summary) - } - - /// Query usage grouped by model. - pub fn query_by_model(&self) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - - let mut stmt = conn - .prepare( - "SELECT model, COALESCE(SUM(cost_usd), 0.0), COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0), COUNT(*) - FROM usage_events GROUP BY model ORDER BY SUM(cost_usd) DESC", - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let rows = stmt - .query_map([], |row| { - Ok(ModelUsage { - model: row.get(0)?, - total_cost_usd: row.get(1)?, - total_input_tokens: row.get::<_, i64>(2)? as u64, - total_output_tokens: row.get::<_, i64>(3)? as u64, - call_count: row.get::<_, i64>(4)? as u64, - }) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let mut results = Vec::new(); - for row in rows { - results.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(results) - } - - /// Query daily usage breakdown for the last N days. - pub fn query_daily_breakdown(&self, days: u32) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - - let mut stmt = conn - .prepare(&format!( - "SELECT date(timestamp) as day, - COALESCE(SUM(cost_usd), 0.0), - COALESCE(SUM(input_tokens) + SUM(output_tokens), 0), - COUNT(*) - FROM usage_events - WHERE timestamp > datetime('now', '-{days} days') - GROUP BY day - ORDER BY day ASC" - )) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let rows = stmt - .query_map([], |row| { - Ok(DailyBreakdown { - date: row.get(0)?, - cost_usd: row.get(1)?, - tokens: row.get::<_, i64>(2)? as u64, - calls: row.get::<_, i64>(3)? as u64, - }) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - - let mut results = Vec::new(); - for row in rows { - results.push(row.map_err(|e| OpenFangError::Memory(e.to_string()))?); - } - Ok(results) - } - - /// Query the timestamp of the earliest usage event. - pub fn query_first_event_date(&self) -> OpenFangResult> { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let result: Option = conn - .query_row("SELECT MIN(timestamp) FROM usage_events", [], |row| { - row.get(0) - }) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(result) - } - - /// Query today's total cost across all agents. - pub fn query_today_cost(&self) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let cost: f64 = conn - .query_row( - "SELECT COALESCE(SUM(cost_usd), 0.0) FROM usage_events - WHERE timestamp > datetime('now', 'start of day')", - [], - |row| row.get(0), - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(cost) - } - - /// Delete usage events older than the given number of days. - pub fn cleanup_old(&self, days: u32) -> OpenFangResult { - let conn = self - .conn - .lock() - .map_err(|e| OpenFangError::Internal(e.to_string()))?; - let deleted = conn - .execute( - &format!( - "DELETE FROM usage_events WHERE timestamp < datetime('now', '-{days} days')" - ), - [], - ) - .map_err(|e| OpenFangError::Memory(e.to_string()))?; - Ok(deleted) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::migration::run_migrations; - - fn setup() -> UsageStore { - let conn = Connection::open_in_memory().unwrap(); - run_migrations(&conn).unwrap(); - UsageStore::new(Arc::new(Mutex::new(conn))) - } - - #[test] - fn test_record_and_query_summary() { - let store = setup(); - let agent_id = AgentId::new(); - - store - .record(&UsageRecord { - agent_id, - model: "claude-haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.001, - tool_calls: 2, - }) - .unwrap(); - - store - .record(&UsageRecord { - agent_id, - model: "claude-sonnet".to_string(), - input_tokens: 500, - output_tokens: 200, - cost_usd: 0.01, - tool_calls: 1, - }) - .unwrap(); - - let summary = store.query_summary(Some(agent_id)).unwrap(); - assert_eq!(summary.call_count, 2); - assert_eq!(summary.total_input_tokens, 600); - assert_eq!(summary.total_output_tokens, 250); - assert!((summary.total_cost_usd - 0.011).abs() < 0.0001); - assert_eq!(summary.total_tool_calls, 3); - } - - #[test] - fn test_query_summary_all_agents() { - let store = setup(); - let a1 = AgentId::new(); - let a2 = AgentId::new(); - - store - .record(&UsageRecord { - agent_id: a1, - model: "haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.001, - tool_calls: 0, - }) - .unwrap(); - - store - .record(&UsageRecord { - agent_id: a2, - model: "sonnet".to_string(), - input_tokens: 200, - output_tokens: 100, - cost_usd: 0.005, - tool_calls: 1, - }) - .unwrap(); - - let summary = store.query_summary(None).unwrap(); - assert_eq!(summary.call_count, 2); - assert_eq!(summary.total_input_tokens, 300); - } - - #[test] - fn test_query_by_model() { - let store = setup(); - let agent_id = AgentId::new(); - - for _ in 0..3 { - store - .record(&UsageRecord { - agent_id, - model: "haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.001, - tool_calls: 0, - }) - .unwrap(); - } - - store - .record(&UsageRecord { - agent_id, - model: "sonnet".to_string(), - input_tokens: 500, - output_tokens: 200, - cost_usd: 0.01, - tool_calls: 1, - }) - .unwrap(); - - let by_model = store.query_by_model().unwrap(); - assert_eq!(by_model.len(), 2); - // sonnet should be first (highest cost) - assert_eq!(by_model[0].model, "sonnet"); - assert_eq!(by_model[1].model, "haiku"); - assert_eq!(by_model[1].call_count, 3); - } - - #[test] - fn test_query_hourly() { - let store = setup(); - let agent_id = AgentId::new(); - - store - .record(&UsageRecord { - agent_id, - model: "haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.05, - tool_calls: 0, - }) - .unwrap(); - - let hourly = store.query_hourly(agent_id).unwrap(); - assert!((hourly - 0.05).abs() < 0.001); - } - - #[test] - fn test_query_daily() { - let store = setup(); - let agent_id = AgentId::new(); - - store - .record(&UsageRecord { - agent_id, - model: "haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.123, - tool_calls: 0, - }) - .unwrap(); - - let daily = store.query_daily(agent_id).unwrap(); - assert!((daily - 0.123).abs() < 0.001); - } - - #[test] - fn test_cleanup_old() { - let store = setup(); - let agent_id = AgentId::new(); - - store - .record(&UsageRecord { - agent_id, - model: "haiku".to_string(), - input_tokens: 100, - output_tokens: 50, - cost_usd: 0.001, - tool_calls: 0, - }) - .unwrap(); - - // Cleanup events older than 1 day should not remove today's events - let deleted = store.cleanup_old(1).unwrap(); - assert_eq!(deleted, 0); - - let summary = store.query_summary(None).unwrap(); - assert_eq!(summary.call_count, 1); - } - - #[test] - fn test_empty_summary() { - let store = setup(); - let summary = store.query_summary(None).unwrap(); - assert_eq!(summary.call_count, 0); - assert_eq!(summary.total_cost_usd, 0.0); - } -} diff --git a/crates/openfang-memory/tests/backend_integration.rs b/crates/openfang-memory/tests/backend_integration.rs new file mode 100644 index 0000000000..228afa846d --- /dev/null +++ b/crates/openfang-memory/tests/backend_integration.rs @@ -0,0 +1,996 @@ +//! Integration tests for all storage backends. +//! +//! These tests verify the same operations work identically across: +//! - SQLite (always runs) +//! - PostgreSQL (requires `postgres` feature + running PG instance) +//! - Qdrant semantic store (requires `qdrant` feature + running Qdrant instance) +//! +//! Run with databases up: +//! docker compose --profile db up -d postgres qdrant +//! cargo test -p openfang-memory --features 'postgres,qdrant' --test backend_integration + +use openfang_types::agent::AgentId; +use openfang_types::memory::MemorySource; +use openfang_types::storage::SemanticBackend; +use std::collections::HashMap; + +fn agent_filter(agent_id: AgentId) -> openfang_types::memory::MemoryFilter { + openfang_types::memory::MemoryFilter { + agent_id: Some(agent_id), + ..Default::default() + } +} + +/// Best-effort Qdrant collection cleanup guard. +/// +/// Each Qdrant integration test creates a uniquely-named collection so runs +/// don't collide. Without this guard those collections accumulate on a local +/// Qdrant instance forever. The guard deletes the collection on Drop; any +/// failure is silently ignored (the point is to avoid local-dev cruft, not to +/// make teardown a test dependency). +#[cfg(feature = "qdrant")] +#[allow(dead_code)] +struct QdrantCollectionGuard { + url: String, + collection: String, +} + +#[cfg(feature = "qdrant")] +impl Drop for QdrantCollectionGuard { + fn drop(&mut self) { + let url = self.url.clone(); + let collection = self.collection.clone(); + // Drop runs in sync context; spawn a thread with its own runtime to + // perform the async delete so we don't poke at whatever runtime the + // test was using (which may already be shutting down). + std::thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(_) => return, + }; + rt.block_on(async move { + if let Ok(client) = qdrant_client::Qdrant::from_url(&url).build() { + let _ = client.delete_collection(&collection).await; + } + }); + }) + .join() + .ok(); + } +} + +// ─── SQLite backend tests ────────────────────────────────────────────── + +mod sqlite { + use super::*; + use openfang_memory::backends::SessionBackend; + use openfang_memory::sqlite::KnowledgeStore; + use openfang_memory::sqlite::SemanticStore; + use openfang_memory::sqlite::SessionStore; + use openfang_memory::sqlite::StructuredStore; + use openfang_memory::usage::{UsageRecord}; + use openfang_memory::sqlite::UsageStore; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup() -> Arc> { + unsafe { + rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute::< + *const (), + unsafe extern "C" fn( + *mut rusqlite::ffi::sqlite3, + *mut *const std::os::raw::c_char, + *const rusqlite::ffi::sqlite3_api_routines, + ) -> i32, + >(sqlite_vec::sqlite3_vec_init as *const ()))); + } + let conn = Connection::open_in_memory().unwrap(); + openfang_memory::sqlite::migration::run_migrations(&conn).unwrap(); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn structured_kv_crud() { + let conn = setup(); + let store = StructuredStore::new(conn); + let agent = AgentId::new(); + + store.set(agent, "color", serde_json::json!("blue")).unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), Some(serde_json::json!("blue"))); + + store.set(agent, "color", serde_json::json!("red")).unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), Some(serde_json::json!("red"))); + + let pairs = store.list_kv(agent).unwrap(); + assert_eq!(pairs.len(), 1); + assert_eq!(pairs[0].0, "color"); + + store.delete(agent, "color").unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), None); + assert_eq!(store.get(agent, "nonexistent").unwrap(), None); + } + + #[test] + fn semantic_remember_recall_forget() { + let conn = setup(); + let store = SemanticStore::new(conn); + let agent = AgentId::new(); + + let id = SemanticBackend::remember( + &store, agent, "The quick brown fox jumps over the lazy dog", + MemorySource::Conversation, "episodic", HashMap::new(), None, + ).unwrap(); + + let results = SemanticBackend::recall(&store, "quick brown fox", 10, None, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id); + + let results = SemanticBackend::recall(&store, "fox", 10, Some(agent_filter(AgentId::new())), None).unwrap(); + assert_eq!(results.len(), 0); + + SemanticBackend::forget(&store, id).unwrap(); + let results = SemanticBackend::recall(&store, "fox", 10, None, None).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn semantic_with_embedding() { + let conn = setup(); + let store = SemanticStore::new(conn); + let agent = AgentId::new(); + + let embedding = vec![0.1f32, 0.2, 0.3, 0.4]; + let id = SemanticBackend::remember( + &store, agent, "vector test", MemorySource::System, "episodic", + HashMap::new(), Some(&embedding), + ).unwrap(); + + let results = SemanticBackend::recall(&store, "", 10, None, Some(&embedding)).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id); + + store.update_embedding(id, &[0.5, 0.6, 0.7, 0.8]).unwrap(); + } + + #[test] + fn knowledge_entity_relation() { + let conn = setup(); + let store = KnowledgeStore::new(conn); + + let alice_id = store.add_entity(openfang_types::memory::Entity { + id: String::new(), entity_type: openfang_types::memory::EntityType::Person, + name: "Alice".to_string(), properties: HashMap::new(), + created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + }).unwrap(); + + let acme_id = store.add_entity(openfang_types::memory::Entity { + id: String::new(), entity_type: openfang_types::memory::EntityType::Organization, + name: "Acme Corp".to_string(), properties: HashMap::new(), + created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + }).unwrap(); + + store.add_relation(openfang_types::memory::Relation { + source: alice_id, relation: openfang_types::memory::RelationType::WorksAt, + target: acme_id, properties: HashMap::new(), confidence: 0.9, + created_at: chrono::Utc::now(), + }).unwrap(); + + let matches = store.query_graph(openfang_types::memory::GraphPattern { + source: Some("Alice".to_string()), relation: None, target: None, max_depth: 1, + }).unwrap(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].source.name, "Alice"); + assert_eq!(matches[0].target.name, "Acme Corp"); + } + + #[test] + fn session_crud() { + let conn = setup(); + let store = SessionStore::new(conn); + let agent = AgentId::new(); + + let session = store.create_session(agent).unwrap(); + assert!(store.get_session(session.id).unwrap().is_some()); + + let labeled = store.create_session_with_label(agent, Some("test-label")).unwrap(); + assert_eq!(labeled.label, Some("test-label".to_string())); + + let found = store.find_session_by_label(agent, "test-label").unwrap(); + assert_eq!(found.unwrap().id, labeled.id); + + assert_eq!(store.list_sessions().unwrap().len(), 2); + + store.delete_session(session.id).unwrap(); + assert!(store.get_session(session.id).unwrap().is_none()); + + store.delete_agent_sessions(agent).unwrap(); + assert_eq!(store.list_agent_sessions(agent).unwrap().len(), 0); + } + + #[test] + fn usage_record_and_query() { + let conn = setup(); + let store = UsageStore::new(conn); + let agent = AgentId::new(); + + store.record(&UsageRecord { + agent_id: agent, model: "gpt-4".to_string(), + input_tokens: 100, output_tokens: 50, cost_usd: 0.005, tool_calls: 2, + }).unwrap(); + + assert!(store.query_hourly(agent).unwrap() > 0.0); + assert!(store.query_daily(agent).unwrap() > 0.0); + assert!(store.query_monthly(agent).unwrap() > 0.0); + assert!(store.query_global_hourly().unwrap() > 0.0); + assert!(store.query_global_monthly().unwrap() > 0.0); + + let summary = store.query_summary(Some(agent)).unwrap(); + assert_eq!(summary.total_input_tokens, 100); + assert_eq!(summary.total_output_tokens, 50); + assert_eq!(summary.call_count, 1); + + assert!(!store.query_by_model().unwrap().is_empty()); + assert!(!store.query_daily_breakdown(7).unwrap().is_empty()); + assert!(store.query_today_cost().unwrap() > 0.0); + assert!(store.query_first_event_date().unwrap().is_some()); + } + + #[test] + fn canonical_session() { + let conn = setup(); + let store = SessionStore::new(conn); + let agent = AgentId::new(); + + assert!(store.load_canonical(agent).unwrap().messages.is_empty()); + + let msg = openfang_types::message::Message { + role: openfang_types::message::Role::User, + content: openfang_types::message::MessageContent::Text("hello".to_string()), + }; + assert_eq!(store.append_canonical(agent, &[msg], None).unwrap().messages.len(), 1); + + let (summary, messages) = store.canonical_context(agent, None).unwrap(); + assert!(summary.is_none()); + assert_eq!(messages.len(), 1); + + store.store_llm_summary(agent, "User said hello", vec![]).unwrap(); + assert_eq!(store.canonical_context(agent, None).unwrap().0, Some("User said hello".to_string())); + + store.delete_canonical_session(agent).unwrap(); + } + + #[test] + fn sqlite_paired_devices_crud() { + let conn = setup(); + use openfang_memory::backends::PairedDevicesBackend; + use openfang_memory::sqlite::SqlitePairedDevicesStore; + let store = SqlitePairedDevicesStore::new(conn); + + // Save a device + store.save_paired_device("dev-1", "iPhone", "ios", "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z", Some("token123")).unwrap(); + + // Load — should find it + let devices = store.load_paired_devices().unwrap(); + assert!(devices.iter().any(|d| d["device_id"] == "dev-1")); + + // Update (upsert) + store.save_paired_device("dev-1", "iPhone Pro", "ios", "2025-01-01T00:00:00Z", "2025-06-01T00:00:00Z", None).unwrap(); + let devices = store.load_paired_devices().unwrap(); + let dev = devices.iter().find(|d| d["device_id"] == "dev-1").unwrap(); + assert_eq!(dev["display_name"], "iPhone Pro"); + + // Remove + store.remove_paired_device("dev-1").unwrap(); + let devices = store.load_paired_devices().unwrap(); + assert!(!devices.iter().any(|d| d["device_id"] == "dev-1")); + } + + #[test] + fn sqlite_task_queue_crud() { + let conn = setup(); + use openfang_memory::backends::TaskQueueBackend; + use openfang_memory::sqlite::SqliteTaskQueueStore; + let store = SqliteTaskQueueStore::new(conn); + + // Post a task + let task_id = store.task_post("Review code", "Check auth module", "auditor", "orchestrator").unwrap(); + assert!(!task_id.is_empty()); + + // List pending + let tasks = store.task_list(Some("pending")).unwrap(); + assert!(tasks.iter().any(|t| t["id"] == task_id)); + + // Claim + let claimed = store.task_claim("auditor").unwrap(); + assert!(claimed.is_some()); + assert_eq!(claimed.unwrap()["status"], "in_progress"); + + // Complete + store.task_complete(&task_id, "All good").unwrap(); + let tasks = store.task_list(Some("completed")).unwrap(); + assert!(tasks.iter().any(|t| t["id"] == task_id && t["result"] == "All good")); + } + + #[test] + fn sqlite_audit_log() { + let conn = setup(); + use openfang_memory::backends::AuditBackend; + use openfang_memory::sqlite::SqliteAuditStore; + let store = SqliteAuditStore::new(conn); + + // Append entries + store.append_entry("agent-1", "message", "sent hello", "success").unwrap(); + store.append_entry("agent-1", "tool_call", "ran search", "success").unwrap(); + + // Load all + let entries = store.load_entries(None, 10).unwrap(); + assert!(entries.len() >= 2); + + // Load filtered by agent + let entries = store.load_entries(Some("agent-1"), 10).unwrap(); + assert!(entries.len() >= 2); + + // Load with non-matching agent + let entries = store.load_entries(Some("agent-999"), 10).unwrap(); + assert_eq!(entries.len(), 0); + } +} + +// ─── PostgreSQL backend tests ────────────────────────────────────────── +// All PG tests use #[tokio::test] since the PG store uses block_on(Handle::current()) + +#[cfg(feature = "postgres")] +mod postgres { + use super::*; + use openfang_memory::backends::{SessionBackend, UsageBackend}; + use openfang_memory::postgres::*; + use openfang_memory::usage::UsageRecord; + use openfang_types::storage::{KnowledgeBackend, StructuredBackend}; + + async fn setup() -> Option { + let url = std::env::var("TEST_POSTGRES_URL") + .unwrap_or_else(|_| "postgresql://openfang:openfang@localhost:5432/openfang_test".to_string()); + let pool = create_pool(&url, 2).ok()?; + run_migrations(&pool).await.ok()?; + // No TRUNCATE — each test uses unique AgentId/SessionId so tests + // can run in parallel without interfering with each other. + Some(pool) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_structured_kv_crud() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PgStructuredStore::new(pool); + let agent = AgentId::new(); + + store.set(agent, "color", serde_json::json!("blue")).unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), Some(serde_json::json!("blue"))); + + store.set(agent, "color", serde_json::json!("red")).unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), Some(serde_json::json!("red"))); + + assert_eq!(store.list_kv(agent).unwrap().len(), 1); + + store.delete(agent, "color").unwrap(); + assert_eq!(store.get(agent, "color").unwrap(), None); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_semantic_remember_recall_forget() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PostgresSemanticStore::new(pool); + let agent = AgentId::new(); + + // Toy 4-dim embeddings so the vector-required recall path can be + // exercised without a real embedding model. + let emb_fox: Vec = vec![1.0, 0.1, 0.0, 0.0]; + let emb_query: Vec = vec![0.9, 0.1, 0.0, 0.0]; + let emb_unrelated: Vec = vec![0.0, 0.0, 1.0, 0.0]; + + let id = SemanticBackend::remember( + &store, agent, "The quick brown fox jumps over the lazy dog", + MemorySource::Conversation, "episodic", HashMap::new(), Some(&emb_fox), + ).unwrap(); + + // recall without an embedding must error (pgvector requires one). + let err = SemanticBackend::recall(&store, "quick brown fox", 10, Some(agent_filter(agent)), None); + assert!(err.is_err(), "postgres recall without embedding must fail"); + + let results = SemanticBackend::recall( + &store, "quick brown fox", 10, Some(agent_filter(agent)), Some(&emb_query), + ).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id); + + // Wrong agent filter → no matches even with a good embedding. + let results = SemanticBackend::recall( + &store, "fox", 10, Some(agent_filter(AgentId::new())), Some(&emb_query), + ).unwrap(); + assert_eq!(results.len(), 0); + + SemanticBackend::forget(&store, id).unwrap(); + let results = SemanticBackend::recall( + &store, "fox", 10, Some(agent_filter(agent)), Some(&emb_unrelated), + ).unwrap(); + assert_eq!(results.len(), 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_knowledge_entity_relation() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PgKnowledgeStore::new(pool); + + let alice_id = store.add_entity(openfang_types::memory::Entity { + id: String::new(), entity_type: openfang_types::memory::EntityType::Person, + name: "Alice".to_string(), properties: HashMap::new(), + created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + }).unwrap(); + + let acme_id = store.add_entity(openfang_types::memory::Entity { + id: String::new(), entity_type: openfang_types::memory::EntityType::Organization, + name: "Acme Corp".to_string(), properties: HashMap::new(), + created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + }).unwrap(); + + store.add_relation(openfang_types::memory::Relation { + source: alice_id.clone(), relation: openfang_types::memory::RelationType::WorksAt, + target: acme_id, properties: HashMap::new(), confidence: 0.9, + created_at: chrono::Utc::now(), + }).unwrap(); + + // Query by entity ID (unique) to avoid matching entities from other test runs + let matches = store.query_graph(openfang_types::memory::GraphPattern { + source: Some(alice_id), relation: None, target: None, max_depth: 1, + }).unwrap(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].source.name, "Alice"); + assert_eq!(matches[0].target.name, "Acme Corp"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_session_crud() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PgSessionStore::new(pool); + let agent = AgentId::new(); + + let session = store.create_session(agent).unwrap(); + assert!(store.get_session(session.id).unwrap().is_some()); + + let labeled = store.create_session_with_label(agent, Some("pg-test")).unwrap(); + assert_eq!(store.find_session_by_label(agent, "pg-test").unwrap().unwrap().id, labeled.id); + + assert!(store.list_agent_sessions(agent).unwrap().len() >= 2); + + store.delete_session(session.id).unwrap(); + assert!(store.get_session(session.id).unwrap().is_none()); + store.delete_agent_sessions(agent).unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_usage_record_and_query() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PgUsageStore::new(pool); + let agent = AgentId::new(); + + store.record(&UsageRecord { + agent_id: agent, model: "gpt-4".to_string(), + input_tokens: 100, output_tokens: 50, cost_usd: 0.005, tool_calls: 2, + }).unwrap(); + + assert!(store.query_hourly(agent).unwrap() > 0.0); + let summary = store.query_summary(Some(agent)).unwrap(); + assert_eq!(summary.total_input_tokens, 100); + assert_eq!(summary.call_count, 1); + assert!(!store.query_by_model().unwrap().is_empty()); + assert!(store.query_today_cost().unwrap() > 0.0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_canonical_session() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + let store = PgSessionStore::new(pool); + let agent = AgentId::new(); + + assert!(store.load_canonical(agent).unwrap().messages.is_empty()); + + let msg = openfang_types::message::Message { + role: openfang_types::message::Role::User, + content: openfang_types::message::MessageContent::Text("hello from pg".to_string()), + }; + assert_eq!(store.append_canonical(agent, &[msg], None).unwrap().messages.len(), 1); + + let (summary, messages) = store.canonical_context(agent, None).unwrap(); + assert!(summary.is_none()); + assert_eq!(messages.len(), 1); + + store.store_llm_summary(agent, "PG summary", vec![]).unwrap(); + assert_eq!(store.canonical_context(agent, None).unwrap().0, Some("PG summary".to_string())); + + store.delete_canonical_session(agent).unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_paired_devices_crud() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + use openfang_memory::backends::PairedDevicesBackend; + let store = PgPairedDevicesStore::new(pool); + + // Save a device + store.save_paired_device("dev-1", "iPhone", "ios", "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z", Some("token123")).unwrap(); + + // Load — should find it + let devices = store.load_paired_devices().unwrap(); + assert!(devices.iter().any(|d| d["device_id"] == "dev-1")); + + // Update (upsert) + store.save_paired_device("dev-1", "iPhone Pro", "ios", "2025-01-01T00:00:00Z", "2025-06-01T00:00:00Z", None).unwrap(); + let devices = store.load_paired_devices().unwrap(); + let dev = devices.iter().find(|d| d["device_id"] == "dev-1").unwrap(); + assert_eq!(dev["display_name"], "iPhone Pro"); + + // Remove + store.remove_paired_device("dev-1").unwrap(); + let devices = store.load_paired_devices().unwrap(); + assert!(!devices.iter().any(|d| d["device_id"] == "dev-1")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_task_queue_crud() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + use openfang_memory::backends::TaskQueueBackend; + let store = PgTaskQueueStore::new(pool); + + // Post a task + let task_id = store.task_post("Review code", "Check auth module", "auditor", "orchestrator").unwrap(); + assert!(!task_id.is_empty()); + + // List pending + let tasks = store.task_list(Some("pending")).unwrap(); + assert!(tasks.iter().any(|t| t["id"] == task_id)); + + // Claim + let claimed = store.task_claim("auditor").unwrap(); + assert!(claimed.is_some()); + assert_eq!(claimed.unwrap()["status"], "in_progress"); + + // Complete + store.task_complete(&task_id, "All good").unwrap(); + let tasks = store.task_list(Some("completed")).unwrap(); + assert!(tasks.iter().any(|t| t["id"] == task_id && t["result"] == "All good")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_consolidation() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + use openfang_memory::backends::ConsolidationBackend; + let engine = PgConsolidationEngine::new(pool); + let report = engine.consolidate().unwrap(); + // Just verify it doesn't error — no memories to decay in empty DB + assert_eq!(report.memories_merged, 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pg_audit_log() { + let pool = match setup().await { + Some(p) => p, + None => { eprintln!("SKIP: PostgreSQL not available"); return; } + }; + use openfang_memory::backends::AuditBackend; + let store = PgAuditStore::new(pool); + + // Append entries + store.append_entry("agent-1", "message", "sent hello", "success").unwrap(); + store.append_entry("agent-1", "tool_call", "ran search", "success").unwrap(); + + // Load all + let entries = store.load_entries(None, 10).unwrap(); + assert!(entries.len() >= 2); + + // Load filtered by agent + let entries = store.load_entries(Some("agent-1"), 10).unwrap(); + assert!(entries.len() >= 2); + + // Load with non-matching agent + let entries = store.load_entries(Some("agent-999"), 10).unwrap(); + assert_eq!(entries.len(), 0); + } +} + +// ─── Qdrant semantic backend tests ───────────────────────────────────── +// All Qdrant tests use #[tokio::test] since the store uses block_on(Handle::current()) + +#[cfg(feature = "qdrant")] +mod qdrant_tests { + use super::*; + use openfang_memory::qdrant::QdrantSemanticStore; + + async fn setup() -> Option<(QdrantSemanticStore, QdrantCollectionGuard)> { + let url = std::env::var("TEST_QDRANT_URL") + .unwrap_or_else(|_| "http://localhost:6334".to_string()); + let collection = format!("openfang_test_{}", uuid::Uuid::new_v4().simple()); + let store = QdrantSemanticStore::new(&url, None, &collection).await.ok()?; + let guard = QdrantCollectionGuard { + url, + collection, + }; + Some((store, guard)) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn qdrant_remember_requires_embedding() { + let (store, _guard) = match setup().await { + Some(s) => s, + None => { eprintln!("SKIP: Qdrant not available"); return; } + }; + + let result = SemanticBackend::remember( + &store, AgentId::new(), "no embedding test", + MemorySource::Conversation, "episodic", HashMap::new(), None, + ); + assert!(result.is_err()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn qdrant_remember_recall_forget() { + let (store, _guard) = match setup().await { + Some(s) => s, + None => { eprintln!("SKIP: Qdrant not available"); return; } + }; + let agent = AgentId::new(); + + let embedding = vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; + let id = SemanticBackend::remember( + &store, agent, "Qdrant vector test content", + MemorySource::Conversation, "episodic", HashMap::new(), Some(&embedding), + ).unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let query_emb = vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]; + let results = SemanticBackend::recall(&store, "", 10, None, Some(&query_emb)).unwrap(); + assert!(!results.is_empty(), "Expected results from Qdrant recall"); + assert_eq!(results[0].id, id); + assert!(results[0].content.contains("Qdrant vector test")); + + // Without embedding Qdrant fails fast (C2). + let err = SemanticBackend::recall(&store, "anything", 10, None, None) + .expect_err("Qdrant recall without embedding must error, not return empty"); + let msg = err.to_string(); + assert!( + msg.contains("query_embedding") || msg.contains("embedding"), + "Error should name the missing embedding: {msg}" + ); + + // With matching agent filter + let results = SemanticBackend::recall(&store, "", 10, Some(agent_filter(agent)), Some(&query_emb)).unwrap(); + assert!(!results.is_empty()); + + // With wrong agent filter + let results = SemanticBackend::recall(&store, "", 10, Some(agent_filter(AgentId::new())), Some(&query_emb)).unwrap(); + assert!(results.is_empty()); + + // Forget + SemanticBackend::forget(&store, id).unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let results = SemanticBackend::recall(&store, "", 10, None, Some(&query_emb)).unwrap(); + assert!(results.is_empty(), "Expected 0 results after forget"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn qdrant_update_embedding() { + let (store, _guard) = match setup().await { + Some(s) => s, + None => { eprintln!("SKIP: Qdrant not available"); return; } + }; + let agent = AgentId::new(); + + let original = vec![1.0f32, 0.0, 0.0, 0.0]; + let id = SemanticBackend::remember( + &store, agent, "update embedding test", + MemorySource::System, "episodic", HashMap::new(), Some(&original), + ).unwrap(); + + let updated = vec![0.0f32, 1.0, 0.0, 0.0]; + store.update_embedding(id, &updated).unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let results = SemanticBackend::recall(&store, "", 10, None, Some(&updated)).unwrap(); + assert!(!results.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn qdrant_multiple_memories_ranked() { + let (store, _guard) = match setup().await { + Some(s) => s, + None => { eprintln!("SKIP: Qdrant not available"); return; } + }; + let agent = AgentId::new(); + + let emb1 = vec![1.0f32, 0.0, 0.0, 0.0]; + let emb2 = vec![0.0f32, 1.0, 0.0, 0.0]; + let emb3 = vec![0.9f32, 0.1, 0.0, 0.0]; + + SemanticBackend::remember(&store, agent, "memory A", MemorySource::System, "episodic", HashMap::new(), Some(&emb1)).unwrap(); + SemanticBackend::remember(&store, agent, "memory B", MemorySource::System, "episodic", HashMap::new(), Some(&emb2)).unwrap(); + SemanticBackend::remember(&store, agent, "memory C", MemorySource::System, "episodic", HashMap::new(), Some(&emb3)).unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let results = SemanticBackend::recall(&store, "", 10, None, Some(&emb1)).unwrap(); + assert_eq!(results.len(), 3); + assert!( + results[0].content == "memory A" || results[0].content == "memory C", + "Expected A or C first, got: {}", results[0].content + ); + assert_eq!(results[2].content, "memory B"); + + let results = SemanticBackend::recall(&store, "", 1, None, Some(&emb1)).unwrap(); + assert_eq!(results.len(), 1); + } +} + +// ─── Full substrate integration test ─────────────────────────────────── + +// ─── Full substrate integration test ─────────────────────────────────── + +mod substrate { + use super::*; + use openfang_memory::MemorySubstrate; + use openfang_types::memory::Memory; + + #[tokio::test] + async fn sqlite_substrate_full_cycle() { + let substrate = MemorySubstrate::open_in_memory(0.1).unwrap(); + let agent = AgentId::new(); + + substrate.set(agent, "name", serde_json::json!("test-agent")).await.unwrap(); + assert_eq!(substrate.get(agent, "name").await.unwrap(), Some(serde_json::json!("test-agent"))); + + substrate.remember(agent, "Integration test memory", MemorySource::Conversation, "episodic", HashMap::new()).await.unwrap(); + assert_eq!(substrate.recall("integration test", 10, None).await.unwrap().len(), 1); + + let eid = substrate.add_entity(openfang_types::memory::Entity { + id: String::new(), entity_type: openfang_types::memory::EntityType::Concept, + name: "Rust".to_string(), properties: HashMap::new(), + created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + }).await.unwrap(); + assert!(!eid.is_empty()); + + let session = substrate.create_session(agent).unwrap(); + substrate.delete_session(session.id).unwrap(); + + assert_eq!(substrate.consolidate().await.unwrap().memories_merged, 0); + } +} + +// ─── Hybrid matrix tests (end-to-end substrate with mixed backends) ──── +// +// Exercise `MemorySubstrate::open_async` with mismatched structured + +// semantic backends, the way a real operator would set it up in config.toml. +// Each test skips gracefully when its required services aren't reachable, +// matching the existing per-backend modules above. +#[cfg(any(feature = "postgres", feature = "qdrant"))] +mod hybrid { + use super::*; + use openfang_memory::MemorySubstrate; + use openfang_types::config::{ + MemoryBackendKind, MemoryConfig, SemanticBackendKind, + }; + #[cfg(feature = "postgres")] + use openfang_types::config::PostgresConnConfig; + #[cfg(feature = "qdrant")] + use openfang_types::config::QdrantConnConfig; + use openfang_types::memory::Memory; + + fn tmpdb() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hybrid.db"); + (dir, path) + } + + #[cfg(feature = "postgres")] + fn pg_url() -> Option { + std::env::var("TEST_POSTGRES_URL").ok() + } + + #[cfg(feature = "qdrant")] + fn qdrant_url() -> Option { + std::env::var("TEST_QDRANT_URL").ok() + } + + /// Round-trip `remember` → `recall` via the substrate's async trait, + /// asserting the recalled row carries the same `MemoryId` the store + /// returned. This is the concrete end-to-end contract B1/B2 exist to + /// protect. + async fn remember_recall_roundtrip(substrate: &MemorySubstrate, with_embedding: bool) { + let agent = AgentId::new(); + let mut meta = HashMap::new(); + if with_embedding { + // Fixed-length embedding so Qdrant/pgvector collections are stable + // under repeated test runs against pre-existing dimensions. + meta.insert( + "embedding".into(), + serde_json::json!(vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]), + ); + } + let id = substrate + .remember( + agent, + "hybrid matrix integration content", + MemorySource::Conversation, + "episodic", + meta, + ) + .await + .expect("remember succeeds"); + + let results = substrate + .recall("hybrid matrix", 10, None) + .await + .expect("recall succeeds"); + assert!(!results.is_empty(), "recall returned zero hits"); + // ID stability is the whole point of B1/B2 — the id we got back + // from remember must match the id the recall yields. + assert!( + results.iter().any(|r| r.id == id), + "recalled ids {:?} did not include the stored id {id}", + results.iter().map(|r| r.id).collect::>(), + ); + } + + /// `backend = Sqlite`, `semantic_backend = Qdrant` — the most commonly + /// requested hybrid in the review. + #[cfg(feature = "qdrant")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sqlite_structured_with_qdrant_semantic() { + let Some(qurl) = qdrant_url() else { + eprintln!("SKIP: Qdrant not available"); + return; + }; + let (_dir, path) = tmpdb(); + let collection = format!("openfang_hybrid_{}", uuid::Uuid::new_v4().simple()); + let _guard = super::QdrantCollectionGuard { + url: qurl.clone(), + collection: collection.clone(), + }; + let cfg = MemoryConfig { + backend: MemoryBackendKind::Sqlite, + semantic_backend: Some(SemanticBackendKind::Qdrant), + qdrant: QdrantConnConfig { + qdrant_url: Some(qurl), + qdrant_collection: collection, + ..Default::default() + }, + ..Default::default() + }; + let substrate = match MemorySubstrate::open_async(&path, 0.1, &cfg).await { + Ok(s) => s, + Err(e) => { + eprintln!("SKIP: hybrid sqlite+qdrant open failed: {e}"); + return; + } + }; + remember_recall_roundtrip(&substrate, true).await; + } + + /// `backend = Sqlite`, `semantic_backend = Postgres` — SQLite owns + /// structured rows while pgvector handles semantic recall. + #[cfg(feature = "postgres")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sqlite_structured_with_postgres_semantic() { + let Some(pgurl) = pg_url() else { + eprintln!("SKIP: Postgres not available"); + return; + }; + let (_dir, path) = tmpdb(); + let cfg = MemoryConfig { + backend: MemoryBackendKind::Sqlite, + semantic_backend: Some(SemanticBackendKind::Postgres), + postgres: PostgresConnConfig { + postgres_url: Some(pgurl), + }, + ..Default::default() + }; + let substrate = match MemorySubstrate::open_async(&path, 0.1, &cfg).await { + Ok(s) => s, + Err(e) => { + eprintln!("SKIP: hybrid sqlite+postgres open failed: {e}"); + return; + } + }; + remember_recall_roundtrip(&substrate, true).await; + } + + /// `backend = Postgres`, `semantic_backend = Qdrant` — everything + /// remote; no local SQLite writes for structured data. + #[cfg(all(feature = "postgres", feature = "qdrant"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn postgres_structured_with_qdrant_semantic() { + let (Some(pgurl), Some(qurl)) = (pg_url(), qdrant_url()) else { + eprintln!("SKIP: Postgres or Qdrant not available"); + return; + }; + let (_dir, path) = tmpdb(); + let collection = format!("openfang_hybrid_{}", uuid::Uuid::new_v4().simple()); + let _guard = super::QdrantCollectionGuard { + url: qurl.clone(), + collection: collection.clone(), + }; + let cfg = MemoryConfig { + backend: MemoryBackendKind::Postgres, + semantic_backend: Some(SemanticBackendKind::Qdrant), + postgres: PostgresConnConfig { + postgres_url: Some(pgurl), + }, + qdrant: QdrantConnConfig { + qdrant_url: Some(qurl), + qdrant_collection: collection, + ..Default::default() + }, + ..Default::default() + }; + let substrate = match MemorySubstrate::open_async(&path, 0.1, &cfg).await { + Ok(s) => s, + Err(e) => { + eprintln!("SKIP: hybrid postgres+qdrant open failed: {e}"); + return; + } + }; + remember_recall_roundtrip(&substrate, true).await; + } + + /// Fail-fast: configuring Qdrant at an unreachable URL must surface the + /// error (naming Qdrant and the URL) rather than silently falling back + /// to SQLite. Protects the B4/C2 behavior change. + #[cfg(feature = "qdrant")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn qdrant_unreachable_fails_fast() { + let (_dir, path) = tmpdb(); + let cfg = MemoryConfig { + backend: MemoryBackendKind::Sqlite, + semantic_backend: Some(SemanticBackendKind::Qdrant), + qdrant: QdrantConnConfig { + qdrant_url: Some("http://127.0.0.1:1".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let err = match MemorySubstrate::open_async(&path, 0.1, &cfg).await { + Ok(_) => panic!("unreachable Qdrant must NOT degrade to SQLite"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("qdrant"), + "error should name the failing backend: {msg}" + ); + } +} diff --git a/crates/openfang-runtime/src/audit.rs b/crates/openfang-runtime/src/audit.rs index 4aef914acb..dfd2c29cb5 100644 --- a/crates/openfang-runtime/src/audit.rs +++ b/crates/openfang-runtime/src/audit.rs @@ -4,11 +4,12 @@ //! contains the SHA-256 hash of its own contents concatenated with the hash of //! the previous entry, forming a tamper-evident chain (similar to a blockchain). //! -//! When a database connection is provided (`with_db`), entries are persisted to -//! the `audit_entries` table (schema V8) so the trail survives daemon restarts. +//! When a backend is provided (`with_backend`), entries are persisted to the +//! configured database (SQLite or PostgreSQL) so the trail survives restarts. use chrono::Utc; -use rusqlite::Connection; +use openfang_memory::backends::AuditBackend; +use openfang_types::error::{OpenFangError, OpenFangResult}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex}; @@ -78,15 +79,35 @@ fn compute_entry_hash( hex::encode(hasher.finalize()) } +/// Parse an action string back into an `AuditAction` enum. +fn parse_action(s: &str) -> AuditAction { + match s { + "ToolInvoke" => AuditAction::ToolInvoke, + "CapabilityCheck" => AuditAction::CapabilityCheck, + "AgentSpawn" => AuditAction::AgentSpawn, + "AgentKill" => AuditAction::AgentKill, + "AgentMessage" => AuditAction::AgentMessage, + "MemoryAccess" => AuditAction::MemoryAccess, + "FileAccess" => AuditAction::FileAccess, + "NetworkAccess" => AuditAction::NetworkAccess, + "ShellExec" => AuditAction::ShellExec, + "AuthAttempt" => AuditAction::AuthAttempt, + "WireConnect" => AuditAction::WireConnect, + "ConfigChange" => AuditAction::ConfigChange, + _ => AuditAction::ToolInvoke, // fallback + } +} + /// An append-only, tamper-evident audit log using a Merkle hash chain. /// /// Thread-safe — all access is serialised through internal mutexes. -/// Optionally backed by SQLite for persistence across daemon restarts. +/// Optionally backed by a persistent store (SQLite or PostgreSQL) via the +/// `AuditBackend` trait. pub struct AuditLog { entries: Mutex>, tip: Mutex, - /// Optional database connection for persistent storage. - db: Option>>, + /// Optional persistent backend (works with any configured database). + db: Option>, } impl AuditLog { @@ -101,82 +122,79 @@ impl AuditLog { } } - /// Creates an audit log backed by a database connection. + /// Creates an audit log backed by a persistent backend. + /// + /// On construction, loads all existing entries from the backend and + /// verifies the Merkle chain integrity. New entries are written to both + /// the in-memory chain and the backend. /// - /// On construction, loads all existing entries from the `audit_entries` - /// table and verifies the Merkle chain integrity. New entries are written - /// to both the in-memory chain and the database. - pub fn with_db(conn: Arc>) -> Self { + /// Fails-closed: if the backend cannot return its entries or the chain + /// integrity check fails, an error is returned and the kernel must refuse + /// to boot. A silent restart with a broken audit trail is not acceptable. + pub fn with_backend(backend: Arc) -> OpenFangResult { let mut entries = Vec::new(); let mut tip = "0".repeat(64); - // Load existing entries from database - if let Ok(db) = conn.lock() { - let result = db.prepare( - "SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq ASC", - ); - if let Ok(mut stmt) = result { - let rows = stmt.query_map([], |row| { - let action_str: String = row.get(3)?; - let action = match action_str.as_str() { - "ToolInvoke" => AuditAction::ToolInvoke, - "CapabilityCheck" => AuditAction::CapabilityCheck, - "AgentSpawn" => AuditAction::AgentSpawn, - "AgentKill" => AuditAction::AgentKill, - "AgentMessage" => AuditAction::AgentMessage, - "MemoryAccess" => AuditAction::MemoryAccess, - "FileAccess" => AuditAction::FileAccess, - "NetworkAccess" => AuditAction::NetworkAccess, - "ShellExec" => AuditAction::ShellExec, - "AuthAttempt" => AuditAction::AuthAttempt, - "WireConnect" => AuditAction::WireConnect, - "ConfigChange" => AuditAction::ConfigChange, - _ => AuditAction::ToolInvoke, // fallback - }; - Ok(AuditEntry { - seq: row.get(0)?, - timestamp: row.get(1)?, - agent_id: row.get(2)?, - action, - detail: row.get(4)?, - outcome: row.get(5)?, - prev_hash: row.get(6)?, - hash: row.get(7)?, - }) - }); - if let Ok(rows) = rows { - for entry in rows.flatten() { - tip = entry.hash.clone(); - entries.push(entry); - } - } - } + // Load existing entries from backend. Propagate any error -- a + // security-critical audit trail must not silently start empty. + let rows = backend.load_entries(None, usize::MAX).map_err(|e| { + tracing::error!("Audit backend load_entries failed on boot: {e}"); + OpenFangError::Memory(format!( + "audit backend load_entries failed on boot: {e}" + )) + })?; + + for row in rows { + let seq = row["seq"].as_u64().unwrap_or(0); + let timestamp = row["timestamp"].as_str().unwrap_or("").to_string(); + let agent_id = row["agent_id"].as_str().unwrap_or("").to_string(); + let action_str = row["action"].as_str().unwrap_or("ToolInvoke"); + let detail = row["detail"].as_str().unwrap_or("").to_string(); + let outcome = row["outcome"].as_str().unwrap_or("").to_string(); + let prev_hash = row["prev_hash"].as_str().unwrap_or("").to_string(); + let hash = row["hash"].as_str().unwrap_or("").to_string(); + + tip = hash.clone(); + entries.push(AuditEntry { + seq, + timestamp, + agent_id, + action: parse_action(action_str), + detail, + outcome, + prev_hash, + hash, + }); } let count = entries.len(); let log = Self { entries: Mutex::new(entries), tip: Mutex::new(tip), - db: Some(conn), + db: Some(backend), }; - // Verify chain integrity on load + // Verify chain integrity on load. A failed integrity check on a + // persistent audit trail means the chain has been tampered with or + // truncated -- refuse to boot rather than pretend everything is fine. if count > 0 { if let Err(e) = log.verify_integrity() { tracing::error!("Audit trail integrity check FAILED on boot: {e}"); - } else { - tracing::info!("Audit trail loaded: {count} entries, chain integrity OK"); + return Err(OpenFangError::Memory(format!( + "audit trail integrity check failed on boot: {e}" + ))); } + tracing::info!("Audit trail loaded: {count} entries, chain integrity OK"); } - log + Ok(log) } /// Records a new auditable event and returns the SHA-256 hash of the entry. /// /// The entry is atomically appended to the chain with the current tip as /// its `prev_hash`, and the tip is advanced to the new hash. - /// If a database connection is available, the entry is also persisted. + /// If a backend is available, the entry is also persisted. pub fn record( &self, agent_id: impl Into, @@ -210,23 +228,14 @@ impl AuditLog { hash: hash.clone(), }; - // Persist to database if available + // Persist to backend if available if let Some(ref db) = self.db { - if let Ok(conn) = db.lock() { - let _ = conn.execute( - "INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - rusqlite::params![ - entry.seq as i64, - &entry.timestamp, - &entry.agent_id, - entry.action.to_string(), - &entry.detail, - &entry.outcome, - &entry.prev_hash, - &entry.hash, - ], - ); - } + let _ = db.append_entry( + &entry.agent_id, + &entry.action.to_string(), + &entry.detail, + &entry.outcome, + ); } entries.push(entry); @@ -371,52 +380,4 @@ mod tests { assert_eq!(log.tip_hash(), h2); assert_ne!(h2, h1); } - - #[test] - fn test_audit_persists_to_db() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - "CREATE TABLE audit_entries ( - seq INTEGER PRIMARY KEY, - timestamp TEXT NOT NULL, - agent_id TEXT NOT NULL, - action TEXT NOT NULL, - detail TEXT NOT NULL, - outcome TEXT NOT NULL, - prev_hash TEXT NOT NULL, - hash TEXT NOT NULL - )", - ) - .unwrap(); - - let db = Arc::new(Mutex::new(conn)); - - // Record entries with DB - let log = AuditLog::with_db(Arc::clone(&db)); - log.record("agent-1", AuditAction::AgentSpawn, "spawn test", "ok"); - log.record("agent-1", AuditAction::ShellExec, "ls", "ok"); - assert_eq!(log.len(), 2); - - // Verify entries in database - let db_conn = db.lock().unwrap(); - let count: i64 = db_conn - .query_row("SELECT COUNT(*) FROM audit_entries", [], |row| row.get(0)) - .unwrap(); - assert_eq!(count, 2); - drop(db_conn); - - // Simulate restart: create new AuditLog from same DB - let log2 = AuditLog::with_db(Arc::clone(&db)); - assert_eq!(log2.len(), 2); - assert!(log2.verify_integrity().is_ok()); - - // Chain continues correctly after restart - log2.record("agent-2", AuditAction::ToolInvoke, "file_read", "ok"); - assert_eq!(log2.len(), 3); - assert!(log2.verify_integrity().is_ok()); - - // Verify tip is correct - let entries = log2.recent(3); - assert_eq!(entries[2].prev_hash, entries[1].hash); - } } diff --git a/crates/openfang-runtime/src/embedding.rs b/crates/openfang-runtime/src/embedding.rs index c3245d879c..1fab6382d7 100644 --- a/crates/openfang-runtime/src/embedding.rs +++ b/crates/openfang-runtime/src/embedding.rs @@ -1,8 +1,8 @@ //! Embedding driver for vector-based semantic memory. //! -//! Provides an `EmbeddingDriver` trait and an OpenAI-compatible implementation -//! that works with any provider offering a `/v1/embeddings` endpoint (OpenAI, -//! Groq, Together, Fireworks, Ollama, etc.). +//! The `EmbeddingDriver` trait and `EmbeddingError` are defined in `openfang-types` +//! and re-exported here for backward compatibility. This module provides the +//! OpenAI-compatible implementation and helper functions. use async_trait::async_trait; use openfang_types::model_catalog::{ @@ -13,18 +13,8 @@ use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; use zeroize::Zeroizing; -/// Error type for embedding operations. -#[derive(Debug, thiserror::Error)] -pub enum EmbeddingError { - #[error("HTTP error: {0}")] - Http(String), - #[error("API error (status {status}): {message}")] - Api { status: u16, message: String }, - #[error("Parse error: {0}")] - Parse(String), - #[error("Missing API key: {0}")] - MissingApiKey(String), -} +// Re-export trait and error from openfang-types for backward compatibility. +pub use openfang_types::embedding::{EmbeddingDriver, EmbeddingError}; /// Configuration for creating an embedding driver. #[derive(Debug, Clone)] @@ -39,25 +29,6 @@ pub struct EmbeddingConfig { pub base_url: String, } -/// Trait for computing text embeddings. -#[async_trait] -pub trait EmbeddingDriver: Send + Sync { - /// Compute embedding vectors for a batch of texts. - async fn embed(&self, texts: &[&str]) -> Result>, EmbeddingError>; - - /// Compute embedding for a single text. - async fn embed_one(&self, text: &str) -> Result, EmbeddingError> { - let results = self.embed(&[text]).await?; - results - .into_iter() - .next() - .ok_or_else(|| EmbeddingError::Parse("Empty embedding response".to_string())) - } - - /// Return the dimensionality of embeddings produced by this driver. - fn dimensions(&self) -> usize; -} - /// OpenAI-compatible embedding driver. /// /// Works with any provider that implements the `/v1/embeddings` endpoint: diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index c6048911f1..cc514be73d 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1616,9 +1616,107 @@ impl Default for DefaultModelConfig { } } +/// Storage backend for structured memory (sessions, usage, audit, etc.). +/// +/// Selects which database drives non-vector memory tables. Vector search is +/// independent and is configured via [`SemanticBackendKind`]. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum MemoryBackendKind { + /// Embedded SQLite database. Default. No external dependencies. + #[default] + Sqlite, + /// PostgreSQL via `deadpool-postgres`. Requires `backend="postgres"` plus + /// a reachable `postgres_url` and the `postgres` cargo feature. + Postgres, +} + +impl std::fmt::Display for MemoryBackendKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + }) + } +} + +/// Vector / semantic search backend. +/// +/// Independent of [`MemoryBackendKind`] — you can run structured data on SQLite +/// and semantic search on Qdrant, for example. When `semantic_backend` is not +/// set in config, substrate uses whichever of SQLite/Postgres matches +/// `backend`. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum SemanticBackendKind { + /// Embedded SQLite + `sqlite-vec` extension. + Sqlite, + /// PostgreSQL + `pgvector`. Requires the `postgres` cargo feature. + Postgres, + /// Qdrant vector database over gRPC. Requires the `qdrant` cargo feature. + Qdrant, + /// Remote memory-api gateway over HTTP. Requires the `http-memory` cargo feature. + Http, +} + +impl std::fmt::Display for SemanticBackendKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + Self::Qdrant => "qdrant", + Self::Http => "http", + }) + } +} + +/// Postgres connection config (used when `backend = Postgres` or `semantic_backend = Postgres`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PostgresConnConfig { + /// PostgreSQL connection URL. + pub postgres_url: Option, +} + +/// Qdrant connection config (used when `semantic_backend = Qdrant`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct QdrantConnConfig { + /// Qdrant server URL. + pub qdrant_url: Option, + /// Env var name holding the Qdrant API key. + pub qdrant_api_key_env: Option, + /// Qdrant collection name for memory vectors. + pub qdrant_collection: String, +} + +impl Default for QdrantConnConfig { + fn default() -> Self { + Self { + qdrant_url: None, + qdrant_api_key_env: None, + qdrant_collection: "openfang_memories".to_string(), + } + } +} + +/// HTTP memory-api gateway config (used when `semantic_backend = Http`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct HttpMemoryConnConfig { + /// HTTP memory API URL. + pub http_url: Option, + /// Env var name holding the HTTP memory API bearer token. + pub http_token_env: Option, +} + /// Memory substrate configuration. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct MemoryConfig { /// Path to SQLite database file. pub sqlite_path: Option, @@ -1637,24 +1735,34 @@ pub struct MemoryConfig { /// How often to run memory consolidation (hours). 0 = disabled. #[serde(default = "default_consolidation_interval")] pub consolidation_interval_hours: u64, - /// Memory backend: "sqlite" (default) or "http". - #[serde(default = "default_memory_backend")] - pub backend: String, - /// HTTP memory API URL (when backend = "http"). - /// e.g., "http://127.0.0.1:5500" + /// Storage backend for structured data, sessions, usage, etc. + /// See [`MemoryBackendKind`] for valid values. Default: [`MemoryBackendKind::Sqlite`]. #[serde(default)] - pub http_url: Option, - /// Env var name holding the HTTP memory API bearer token. + pub backend: MemoryBackendKind, + /// Semantic (vector) backend. See [`SemanticBackendKind`] for valid values. + /// When `None`, semantic search follows `backend` (Sqlite→Sqlite, Postgres→Postgres). #[serde(default)] - pub http_token_env: Option, + pub semantic_backend: Option, + /// PostgreSQL connection pool size. Must be `> 0`; capped at `1000`. + #[serde(default = "default_postgres_pool_size")] + pub postgres_pool_size: u32, + /// Postgres connection config. Flattened in TOML — fields live at the `[memory]` root. + #[serde(flatten)] + pub postgres: PostgresConnConfig, + /// Qdrant connection config. Flattened in TOML — fields live at the `[memory]` root. + #[serde(flatten)] + pub qdrant: QdrantConnConfig, + /// HTTP memory-api gateway config. Flattened in TOML — fields live at the `[memory]` root. + #[serde(flatten)] + pub http: HttpMemoryConnConfig, } fn default_consolidation_interval() -> u64 { 24 } -fn default_memory_backend() -> String { - "sqlite".to_string() +fn default_postgres_pool_size() -> u32 { + 10 } impl Default for MemoryConfig { @@ -1667,9 +1775,12 @@ impl Default for MemoryConfig { embedding_provider: None, embedding_api_key_env: None, consolidation_interval_hours: default_consolidation_interval(), - backend: default_memory_backend(), - http_url: None, - http_token_env: None, + backend: MemoryBackendKind::default(), + semantic_backend: None, + postgres_pool_size: default_postgres_pool_size(), + postgres: PostgresConnConfig::default(), + qdrant: QdrantConnConfig::default(), + http: HttpMemoryConnConfig::default(), } } } diff --git a/crates/openfang-types/src/embedding.rs b/crates/openfang-types/src/embedding.rs new file mode 100644 index 0000000000..70fac30d19 --- /dev/null +++ b/crates/openfang-types/src/embedding.rs @@ -0,0 +1,38 @@ +//! Embedding driver trait for vector-based semantic memory. +//! +//! This module defines the trait interface. Concrete implementations +//! (e.g., OpenAI-compatible driver) live in `openfang-runtime`. + +use async_trait::async_trait; + +/// Error type for embedding operations. +#[derive(Debug, thiserror::Error)] +pub enum EmbeddingError { + #[error("HTTP error: {0}")] + Http(String), + #[error("API error (status {status}): {message}")] + Api { status: u16, message: String }, + #[error("Parse error: {0}")] + Parse(String), + #[error("Missing API key: {0}")] + MissingApiKey(String), +} + +/// Trait for computing text embeddings. +#[async_trait] +pub trait EmbeddingDriver: Send + Sync { + /// Compute embedding vectors for a batch of texts. + async fn embed(&self, texts: &[&str]) -> Result>, EmbeddingError>; + + /// Compute embedding for a single text. + async fn embed_one(&self, text: &str) -> Result, EmbeddingError> { + let results = self.embed(&[text]).await?; + results + .into_iter() + .next() + .ok_or_else(|| EmbeddingError::Parse("Empty embedding response".to_string())) + } + + /// Return the dimensionality of embeddings produced by this driver. + fn dimensions(&self) -> usize; +} diff --git a/crates/openfang-types/src/lib.rs b/crates/openfang-types/src/lib.rs index de9df5975d..17c6492b83 100644 --- a/crates/openfang-types/src/lib.rs +++ b/crates/openfang-types/src/lib.rs @@ -9,6 +9,7 @@ pub mod capability; pub mod commands; pub mod comms; pub mod config; +pub mod embedding; pub mod error; pub mod event; pub mod manifest_signing; @@ -18,6 +19,7 @@ pub mod message; pub mod model_catalog; pub mod scheduler; pub mod serde_compat; +pub mod storage; pub mod taint; pub mod tool; pub mod tool_compat; diff --git a/crates/openfang-types/src/storage.rs b/crates/openfang-types/src/storage.rs new file mode 100644 index 0000000000..ff6d878b7b --- /dev/null +++ b/crates/openfang-types/src/storage.rs @@ -0,0 +1,78 @@ +//! Backend storage traits for pluggable persistence. +//! +//! These traits define the interface for each storage concern. Concrete +//! implementations (SQLite, PostgreSQL, Qdrant) live in `openfang-memory`. +//! +//! Traits that reference types local to `openfang-memory` (Session, UsageRecord, +//! etc.) are defined there instead. This module contains only the traits whose +//! types are fully available in `openfang-types`. + +use crate::agent::{AgentEntry, AgentId}; +use crate::error::OpenFangResult; +use crate::memory::{ + Entity, GraphMatch, GraphPattern, MemoryFilter, MemoryFragment, MemoryId, MemorySource, + Relation, +}; +use serde_json::Value; +use std::collections::HashMap; + +/// Backend for agent registry and key-value storage. +pub trait StructuredBackend: Send + Sync { + /// Get a value by key for a specific agent. + fn get(&self, agent_id: AgentId, key: &str) -> OpenFangResult>; + /// Set a key-value pair for a specific agent. + fn set(&self, agent_id: AgentId, key: &str, value: Value) -> OpenFangResult<()>; + /// Delete a key-value pair. + fn delete(&self, agent_id: AgentId, key: &str) -> OpenFangResult<()>; + /// List all key-value pairs for an agent. + fn list_kv(&self, agent_id: AgentId) -> OpenFangResult>; + /// Save an agent entry. + fn save_agent(&self, entry: &AgentEntry) -> OpenFangResult<()>; + /// Load an agent by ID. + fn load_agent(&self, agent_id: AgentId) -> OpenFangResult>; + /// Remove an agent and its data. + fn remove_agent(&self, agent_id: AgentId) -> OpenFangResult<()>; + /// Load all agents. + fn load_all_agents(&self) -> OpenFangResult>; + /// List agents as (id, name, state) tuples. + fn list_agents(&self) -> OpenFangResult>; +} + +/// Backend for semantic memory with vector search. +pub trait SemanticBackend: Send + Sync { + /// Store a memory fragment, optionally with a pre-computed embedding. + fn remember( + &self, + agent_id: AgentId, + content: &str, + source: MemorySource, + scope: &str, + metadata: HashMap, + embedding: Option<&[f32]>, + ) -> OpenFangResult; + + /// Search for relevant memories, optionally using a query embedding for vector search. + fn recall( + &self, + query: &str, + limit: usize, + filter: Option, + query_embedding: Option<&[f32]>, + ) -> OpenFangResult>; + + /// Soft-delete a memory fragment. + fn forget(&self, id: MemoryId) -> OpenFangResult<()>; + + /// Update the embedding for an existing memory. + fn update_embedding(&self, id: MemoryId, embedding: &[f32]) -> OpenFangResult<()>; +} + +/// Backend for the knowledge graph. +pub trait KnowledgeBackend: Send + Sync { + /// Add an entity to the graph. + fn add_entity(&self, entity: Entity) -> OpenFangResult; + /// Add a relation between entities. + fn add_relation(&self, relation: Relation) -> OpenFangResult; + /// Query the graph by pattern. + fn query_graph(&self, pattern: GraphPattern) -> OpenFangResult>; +} diff --git a/docker-compose.yml b/docker-compose.yml index b09f85f034..ec05fe18c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: build: . # image: ghcr.io/rightnow-ai/openfang:latest # Uncomment when GHCR is public ports: - - "4200:4200" + - "${OPENFANG_PORT:-4200}:4200" volumes: - openfang-data:/data environment: @@ -21,5 +21,46 @@ services: - SLACK_APP_TOKEN=${SLACK_APP_TOKEN:-} restart: unless-stopped + # Run tests: docker compose --profile test run test + test: + build: + context: . + target: tester + profiles: ["test"] + + # ── Database backends for integration testing ───────────────────────── + # Run: docker compose --profile db up -d + # + # Dev/CI profile only. DO NOT reuse these credentials in production — + # every setting below is overridable via the environment (see .env.example + # or export the vars before running docker compose). + postgres: + image: pgvector/pgvector:pg18 + environment: + POSTGRES_USER: ${POSTGRES_USER:-openfang} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openfang_dev} + POSTGRES_DB: ${POSTGRES_DB:-openfang_test} + ports: + - "${POSTGRES_PORT:-5432}:5432" + profiles: ["db", "postgres"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openfang}"] + interval: 2s + timeout: 5s + retries: 10 + + # Dev/CI profile only. Override QDRANT_* env vars for non-default setups. + qdrant: + image: ${QDRANT_IMAGE:-qdrant/qdrant:latest} + ports: + - "${QDRANT_HTTP_PORT:-6333}:6333" + - "${QDRANT_GRPC_PORT:-6334}:6334" + profiles: ["db", "qdrant"] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:6333/healthz || exit 1"] + interval: 2s + timeout: 5s + retries: 10 + volumes: openfang-data: