diff --git a/Cargo.lock b/Cargo.lock index 0a5b76d04..37dbd57f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "accessory" @@ -11,7 +11,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -194,7 +194,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -280,9 +280,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", @@ -361,7 +361,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -372,7 +372,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -389,9 +389,9 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" dependencies = [ "async-utility", "futures", @@ -426,9 +426,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -508,7 +508,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -621,7 +621,7 @@ checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -700,9 +700,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" dependencies = [ "allocator-api2", ] @@ -730,7 +730,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -920,9 +920,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1030,7 +1030,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1493,7 +1493,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1517,7 +1517,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1528,7 +1528,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1619,7 +1619,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1633,7 +1633,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1648,9 +1648,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -1692,7 +1692,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1704,7 +1704,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.116", + "syn 2.0.117", "unicode-xid", ] @@ -1790,7 +1790,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1810,7 +1810,7 @@ checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1941,7 +1941,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2122,7 +2122,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2323,7 +2323,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2563,7 +2563,7 @@ checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2638,17 +2638,17 @@ dependencies = [ [[package]] name = "hidapi" -version = "2.6.4" +version = "2.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "565dd4c730b8f8b2c0fb36df6be12e5470ae10895ddcc4e9dcfbfb495de202b0" +checksum = "d1b71e1f4791fb9e93b9d7ee03d70b501ab48f6151432fbcadeabc30fe15396e" dependencies = [ "basic-udev", "cc", "cfg-if", "libc", - "nix 0.27.1", + "nix 0.30.1", "pkg-config", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2984,7 +2984,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3104,7 +3104,7 @@ checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3281,9 +3281,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -3548,7 +3548,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3560,7 +3560,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3573,7 +3573,7 @@ dependencies = [ "macroific_core", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3610,7 +3610,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3648,7 +3648,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3917,7 +3917,7 @@ dependencies = [ "macroific", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3976,7 +3976,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4116,17 +4116,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "libc", -] - [[package]] name = "nix" version = "0.29.0" @@ -4712,29 +4701,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4947,7 +4936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5100,7 +5089,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5113,7 +5102,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5157,9 +5146,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ "bitflags 2.11.0", "memchr", @@ -5477,7 +5466,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5519,9 +5508,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -5765,7 +5754,7 @@ dependencies = [ "quote", "ruma-identifiers-validation", "serde", - "syn 2.0.116", + "syn 2.0.117", "toml 0.9.12+spec-1.1.0", ] @@ -5819,7 +5808,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.116", + "syn 2.0.117", "walkdir", ] @@ -5886,9 +5875,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -6026,7 +6015,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6061,7 +6050,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6086,9 +6075,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", "core-foundation", @@ -6099,9 +6088,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -6180,7 +6169,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6191,7 +6180,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6273,9 +6262,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64", "chrono", @@ -6365,9 +6354,9 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ "dirs 6.0.0", ] @@ -6563,7 +6552,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6585,9 +6574,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -6611,7 +6600,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6707,7 +6696,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6718,7 +6707,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6829,7 +6818,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7072,9 +7061,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f32a6f80051a4111560201420c7885d0082ba9efe2ab61875c587bb6b18b9a0" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64", @@ -7093,9 +7082,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f86539c0089bfd09b1f8c0ab0239d80392af74c21bc9e0f15e1b4aca4c1647f" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost 0.14.3", @@ -7171,7 +7160,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7296,7 +7285,7 @@ checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7359,9 +7348,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -7678,7 +7667,7 @@ checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7844,9 +7833,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -7857,9 +7846,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -7871,9 +7860,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7881,22 +7870,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -8136,7 +8125,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser 0.221.3", @@ -8239,7 +8228,7 @@ checksum = "1e91092e6cf77390eeccee273846a9327f3e8f91c3c6280f60f37809f0e62d29" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8334,9 +8323,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -8459,7 +8448,7 @@ dependencies = [ "proc-macro2", "quote", "shellexpand 2.1.2", - "syn 2.0.116", + "syn 2.0.117", "witx", ] @@ -8471,7 +8460,7 @@ checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wiggle-generate", ] @@ -8550,7 +8539,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8561,7 +8550,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8588,15 +8577,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -8633,21 +8613,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -8681,12 +8646,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8699,12 +8658,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8717,12 +8670,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8747,12 +8694,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8765,12 +8706,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8783,12 +8718,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8801,12 +8730,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8906,7 +8829,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8922,7 +8845,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -9083,7 +9006,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -9095,7 +9018,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -9164,7 +9087,7 @@ dependencies = [ "serde_ignored", "serde_json", "sha2", - "shellexpand 3.1.1", + "shellexpand 3.1.2", "tempfile", "thiserror 2.0.18", "tokio", @@ -9219,22 +9142,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9254,7 +9177,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure", ] @@ -9275,7 +9198,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9319,7 +9242,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9330,7 +9253,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] diff --git a/src/config/mod.rs b/src/config/mod.rs index cb5acb468..a5442c39d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,6 +8,7 @@ pub use schema::{ AgentConfig, AgentsIpcConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CoordinationConfig, CostConfig, CronConfig, DelegateAgentConfig, DiscordConfig, + EconomicConfig, EconomicTokenPricing, DockerRuntimeConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, GroupReplyConfig, GroupReplyMode, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, diff --git a/src/config/schema.rs b/src/config/schema.rs index a78596048..59570659e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -237,6 +237,11 @@ pub struct Config { #[serde(default)] pub cost: CostConfig, + /// Economic agent survival tracking (`[economic]`). + /// Tracks balance, token costs, work income, and survival status. + #[serde(default)] + pub economic: EconomicConfig, + /// Peripheral board configuration for hardware integration (`[peripherals]`). #[serde(default)] pub peripherals: PeripheralsConfig, @@ -1133,6 +1138,83 @@ pub struct PeripheralBoardConfig { pub baud: u32, } +// ── Economic Agent Config ───────────────────────────────────────── + +/// Token pricing configuration for economic tracking. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct EconomicTokenPricing { + /// Price per million input tokens (USD) + #[serde(default = "default_input_price")] + pub input_price_per_million: f64, + /// Price per million output tokens (USD) + #[serde(default = "default_output_price")] + pub output_price_per_million: f64, +} + +fn default_input_price() -> f64 { + 3.0 // Claude Sonnet 4 input price +} + +fn default_output_price() -> f64 { + 15.0 // Claude Sonnet 4 output price +} + +impl Default for EconomicTokenPricing { + fn default() -> Self { + Self { + input_price_per_million: default_input_price(), + output_price_per_million: default_output_price(), + } + } +} + +/// Economic agent survival tracking configuration (`[economic]` section). +/// +/// Implements the ClawWork economic model for AI agents, tracking +/// balance, costs, income, and survival status. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct EconomicConfig { + /// Enable economic tracking (default: false) + #[serde(default)] + pub enabled: bool, + + /// Starting balance in USD (default: 1000.0) + #[serde(default = "default_initial_balance")] + pub initial_balance: f64, + + /// Token pricing configuration + #[serde(default)] + pub token_pricing: EconomicTokenPricing, + + /// Minimum evaluation score (0.0-1.0) to receive payment (default: 0.6) + #[serde(default = "default_min_evaluation_threshold")] + pub min_evaluation_threshold: f64, + + /// Data directory for economic state persistence (relative to workspace) + #[serde(default)] + pub data_path: Option, +} + +fn default_initial_balance() -> f64 { + 1000.0 +} + +fn default_min_evaluation_threshold() -> f64 { + 0.6 +} + +impl Default for EconomicConfig { + fn default() -> Self { + Self { + enabled: false, + initial_balance: default_initial_balance(), + token_pricing: EconomicTokenPricing::default(), + min_evaluation_threshold: default_min_evaluation_threshold(), + data_path: None, + } + } +} + fn default_peripheral_transport() -> String { "serial".into() } @@ -5116,6 +5198,7 @@ impl Default for Config { proxy: ProxyConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), + economic: EconomicConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), coordination: CoordinationConfig::default(), @@ -7747,6 +7830,7 @@ default_temperature = 0.7 agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), + economic: EconomicConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hooks: HooksConfig::default(), @@ -8118,6 +8202,7 @@ tool_dispatcher = "xml" agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), + economic: EconomicConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hooks: HooksConfig::default(), diff --git a/src/economic/classifier.rs b/src/economic/classifier.rs new file mode 100644 index 000000000..0db6bc7df --- /dev/null +++ b/src/economic/classifier.rs @@ -0,0 +1,724 @@ +//! Task Classifier for ZeroClaw Economic Agents +//! +//! Classifies work instructions into 44 BLS occupations with wage data +//! to estimate task value for agent economics. +//! +//! ## Overview +//! +//! The classifier matches task instructions to standardized occupation +//! categories using keyword matching and heuristics, then calculates +//! expected payment based on BLS hourly wage data. +//! +//! ## Example +//! +//! ```rust,ignore +//! use zeroclaw::economic::classifier::{TaskClassifier, OccupationCategory}; +//! +//! let classifier = TaskClassifier::new(); +//! let result = classifier.classify("Write a REST API in Rust").await?; +//! +//! println!("Occupation: {}", result.occupation); +//! println!("Hourly wage: ${:.2}", result.hourly_wage); +//! println!("Estimated hours: {:.2}", result.estimated_hours); +//! println!("Max payment: ${:.2}", result.max_payment); +//! ``` + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Occupation category groupings based on BLS major groups +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum OccupationCategory { + /// Software, IT, engineering roles + TechnologyEngineering, + /// Finance, accounting, management, sales + BusinessFinance, + /// Medical, nursing, social work + HealthcareSocialServices, + /// Legal, media, operations, other professional + LegalMediaOperations, +} + +impl OccupationCategory { + /// Returns a human-readable name for the category + pub fn display_name(&self) -> &'static str { + match self { + Self::TechnologyEngineering => "Technology & Engineering", + Self::BusinessFinance => "Business & Finance", + Self::HealthcareSocialServices => "Healthcare & Social Services", + Self::LegalMediaOperations => "Legal, Media & Operations", + } + } +} + +/// A single occupation with BLS wage data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Occupation { + /// Official BLS occupation name + pub name: String, + /// Hourly wage in USD (BLS median) + pub hourly_wage: f64, + /// Category grouping + pub category: OccupationCategory, + /// Keywords for matching + #[serde(skip)] + pub keywords: Vec<&'static str>, +} + +/// Result of classifying a task instruction +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationResult { + /// Matched occupation name + pub occupation: String, + /// BLS hourly wage for this occupation + pub hourly_wage: f64, + /// Estimated hours to complete task + pub estimated_hours: f64, + /// Maximum payment (hours × wage) + pub max_payment: f64, + /// Classification confidence (0.0 - 1.0) + pub confidence: f64, + /// Category of the matched occupation + pub category: OccupationCategory, + /// Brief reasoning for the classification + pub reasoning: String, +} + +/// Task classifier that maps instructions to BLS occupations +#[derive(Debug)] +pub struct TaskClassifier { + occupations: Vec, + keyword_index: HashMap<&'static str, Vec>, + fallback_occupation: String, + fallback_wage: f64, +} + +impl Default for TaskClassifier { + fn default() -> Self { + Self::new() + } +} + +impl TaskClassifier { + /// Create a new TaskClassifier with embedded BLS occupation data + pub fn new() -> Self { + let occupations = Self::load_occupations(); + let keyword_index = Self::build_keyword_index(&occupations); + + Self { + occupations, + keyword_index, + fallback_occupation: "General and Operations Managers".to_string(), + fallback_wage: 64.0, + } + } + + /// Load all 44 BLS occupations with wage data + fn load_occupations() -> Vec { + use OccupationCategory::*; + + vec![ + // Technology & Engineering + Occupation { + name: "Software Developers".into(), + hourly_wage: 69.50, + category: TechnologyEngineering, + keywords: vec![ + "software", "code", "programming", "developer", "rust", "python", + "javascript", "api", "backend", "frontend", "fullstack", "app", + "application", "debug", "refactor", "implement", "algorithm", + ], + }, + Occupation { + name: "Computer and Information Systems Managers".into(), + hourly_wage: 90.38, + category: TechnologyEngineering, + keywords: vec![ + "it manager", "cto", "tech lead", "infrastructure", "systems", + "devops", "cloud", "architecture", "platform", "enterprise", + ], + }, + Occupation { + name: "Industrial Engineers".into(), + hourly_wage: 51.87, + category: TechnologyEngineering, + keywords: vec![ + "industrial", "process", "optimization", "efficiency", "workflow", + "manufacturing", "lean", "six sigma", "production", + ], + }, + Occupation { + name: "Mechanical Engineers".into(), + hourly_wage: 52.92, + category: TechnologyEngineering, + keywords: vec![ + "mechanical", "cad", "solidworks", "machinery", "thermal", + "hvac", "automotive", "robotics", + ], + }, + // Business & Finance + Occupation { + name: "Accountants and Auditors".into(), + hourly_wage: 44.96, + category: BusinessFinance, + keywords: vec![ + "accounting", "audit", "tax", "bookkeeping", "financial statements", + "gaap", "ledger", "reconciliation", "cpa", + ], + }, + Occupation { + name: "Administrative Services Managers".into(), + hourly_wage: 60.59, + category: BusinessFinance, + keywords: vec![ + "administrative", "office manager", "facilities", "operations", + "scheduling", "coordination", + ], + }, + Occupation { + name: "Buyers and Purchasing Agents".into(), + hourly_wage: 39.29, + category: BusinessFinance, + keywords: vec![ + "procurement", "purchasing", "vendor", "supplier", "sourcing", + "negotiation", "contracts", + ], + }, + Occupation { + name: "Compliance Officers".into(), + hourly_wage: 40.86, + category: BusinessFinance, + keywords: vec![ + "compliance", "regulatory", "audit", "policy", "governance", + "risk", "sox", "gdpr", + ], + }, + Occupation { + name: "Financial Managers".into(), + hourly_wage: 86.76, + category: BusinessFinance, + keywords: vec![ + "cfo", "finance director", "treasury", "budget", "financial planning", + "investment management", + ], + }, + Occupation { + name: "Financial and Investment Analysts".into(), + hourly_wage: 56.01, + category: BusinessFinance, + keywords: vec![ + "financial analysis", "investment", "portfolio", "stock", "equity", + "valuation", "modeling", "dcf", "market research", + ], + }, + Occupation { + name: "General and Operations Managers".into(), + hourly_wage: 64.00, + category: BusinessFinance, + keywords: vec![ + "operations", "general manager", "director", "oversee", "manage", + "strategy", "leadership", "business", + ], + }, + Occupation { + name: "Market Research Analysts and Marketing Specialists".into(), + hourly_wage: 41.58, + category: BusinessFinance, + keywords: vec![ + "market research", "marketing", "campaign", "branding", "seo", + "advertising", "analytics", "customer", "segment", + ], + }, + Occupation { + name: "Personal Financial Advisors".into(), + hourly_wage: 77.02, + category: BusinessFinance, + keywords: vec![ + "financial advisor", "wealth", "retirement", "401k", "ira", + "estate planning", "insurance", + ], + }, + Occupation { + name: "Project Management Specialists".into(), + hourly_wage: 51.97, + category: BusinessFinance, + keywords: vec![ + "project manager", "pmp", "agile", "scrum", "sprint", "milestone", + "timeline", "stakeholder", "deliverable", + ], + }, + Occupation { + name: "Property, Real Estate, and Community Association Managers".into(), + hourly_wage: 39.77, + category: BusinessFinance, + keywords: vec![ + "property", "real estate", "landlord", "tenant", "lease", + "hoa", "community", + ], + }, + Occupation { + name: "Sales Managers".into(), + hourly_wage: 77.37, + category: BusinessFinance, + keywords: vec![ + "sales manager", "revenue", "quota", "pipeline", "crm", + "account executive", "territory", + ], + }, + Occupation { + name: "Marketing and Sales Managers".into(), + hourly_wage: 79.35, + category: BusinessFinance, + keywords: vec![ + "vp sales", "cmo", "growth", "go-to-market", "demand gen", + ], + }, + Occupation { + name: "Financial Specialists".into(), + hourly_wage: 48.12, + category: BusinessFinance, + keywords: vec![ + "financial specialist", "credit", "loan", "underwriting", + ], + }, + Occupation { + name: "Securities, Commodities, and Financial Services Sales Agents".into(), + hourly_wage: 48.12, + category: BusinessFinance, + keywords: vec![ + "broker", "securities", "commodities", "trading", "series 7", + ], + }, + Occupation { + name: "Business Operations Specialists, All Other".into(), + hourly_wage: 44.41, + category: BusinessFinance, + keywords: vec![ + "business analyst", "operations specialist", "process improvement", + ], + }, + Occupation { + name: "Claims Adjusters, Examiners, and Investigators".into(), + hourly_wage: 37.87, + category: BusinessFinance, + keywords: vec![ + "claims", "insurance", "adjuster", "investigator", "fraud", + ], + }, + Occupation { + name: "Transportation, Storage, and Distribution Managers".into(), + hourly_wage: 55.77, + category: BusinessFinance, + keywords: vec![ + "logistics", "supply chain", "warehouse", "distribution", "shipping", + "inventory", "fulfillment", + ], + }, + Occupation { + name: "Industrial Production Managers".into(), + hourly_wage: 62.11, + category: BusinessFinance, + keywords: vec![ + "production manager", "plant manager", "manufacturing operations", + ], + }, + Occupation { + name: "Lodging Managers".into(), + hourly_wage: 37.24, + category: BusinessFinance, + keywords: vec![ + "hotel", "hospitality", "lodging", "resort", "concierge", + ], + }, + Occupation { + name: "Real Estate Brokers".into(), + hourly_wage: 39.77, + category: BusinessFinance, + keywords: vec![ + "real estate broker", "realtor", "mls", "listing", + ], + }, + Occupation { + name: "Managers, All Other".into(), + hourly_wage: 72.06, + category: BusinessFinance, + keywords: vec!["manager", "supervisor", "team lead"], + }, + // Healthcare & Social Services + Occupation { + name: "Medical and Health Services Managers".into(), + hourly_wage: 66.22, + category: HealthcareSocialServices, + keywords: vec![ + "healthcare", "hospital", "clinic", "medical", "health services", + "patient", "hipaa", + ], + }, + Occupation { + name: "Social and Community Service Managers".into(), + hourly_wage: 41.39, + category: HealthcareSocialServices, + keywords: vec![ + "social services", "community", "nonprofit", "outreach", + "case management", "welfare", + ], + }, + Occupation { + name: "Child, Family, and School Social Workers".into(), + hourly_wage: 41.39, + category: HealthcareSocialServices, + keywords: vec![ + "social worker", "child welfare", "family services", "school counselor", + ], + }, + Occupation { + name: "Registered Nurses".into(), + hourly_wage: 66.22, + category: HealthcareSocialServices, + keywords: vec!["nurse", "rn", "nursing", "patient care", "clinical"], + }, + Occupation { + name: "Nurse Practitioners".into(), + hourly_wage: 66.22, + category: HealthcareSocialServices, + keywords: vec!["np", "nurse practitioner", "aprn", "prescribe"], + }, + Occupation { + name: "Pharmacists".into(), + hourly_wage: 66.22, + category: HealthcareSocialServices, + keywords: vec!["pharmacy", "pharmacist", "medication", "prescription", "drug"], + }, + Occupation { + name: "Medical Secretaries and Administrative Assistants".into(), + hourly_wage: 66.22, + category: HealthcareSocialServices, + keywords: vec![ + "medical secretary", "medical records", "ehr", "scheduling appointments", + ], + }, + // Legal, Media & Operations + Occupation { + name: "Lawyers".into(), + hourly_wage: 44.41, + category: LegalMediaOperations, + keywords: vec![ + "lawyer", "attorney", "legal", "contract", "litigation", + "counsel", "law", "paralegal", + ], + }, + Occupation { + name: "Editors".into(), + hourly_wage: 72.06, + category: LegalMediaOperations, + keywords: vec![ + "editor", "editing", "proofread", "copy edit", "manuscript", + "publication", + ], + }, + Occupation { + name: "Film and Video Editors".into(), + hourly_wage: 68.15, + category: LegalMediaOperations, + keywords: vec![ + "video editor", "film", "premiere", "final cut", "davinci", + "post-production", + ], + }, + Occupation { + name: "Audio and Video Technicians".into(), + hourly_wage: 41.86, + category: LegalMediaOperations, + keywords: vec![ + "audio", "video", "av", "broadcast", "streaming", "recording", + ], + }, + Occupation { + name: "Producers and Directors".into(), + hourly_wage: 41.86, + category: LegalMediaOperations, + keywords: vec![ + "producer", "director", "production", "creative director", + "content", "show", + ], + }, + Occupation { + name: "News Analysts, Reporters, and Journalists".into(), + hourly_wage: 68.15, + category: LegalMediaOperations, + keywords: vec![ + "journalist", "reporter", "news", "article", "press", + "interview", "story", + ], + }, + Occupation { + name: "Entertainment and Recreation Managers, Except Gambling".into(), + hourly_wage: 41.86, + category: LegalMediaOperations, + keywords: vec![ + "entertainment", "recreation", "event", "venue", "concert", + ], + }, + Occupation { + name: "Recreation Workers".into(), + hourly_wage: 41.86, + category: LegalMediaOperations, + keywords: vec!["recreation", "activity", "fitness", "sports"], + }, + Occupation { + name: "Customer Service Representatives".into(), + hourly_wage: 44.41, + category: LegalMediaOperations, + keywords: vec![ + "customer service", "support", "helpdesk", "ticket", "chat", + ], + }, + Occupation { + name: "Private Detectives and Investigators".into(), + hourly_wage: 37.87, + category: LegalMediaOperations, + keywords: vec![ + "detective", "investigator", "background check", "surveillance", + ], + }, + Occupation { + name: "First-Line Supervisors of Police and Detectives".into(), + hourly_wage: 72.06, + category: LegalMediaOperations, + keywords: vec!["police", "law enforcement", "security supervisor"], + }, + ] + } + + /// Build keyword → occupation index for fast lookup + fn build_keyword_index(occupations: &[Occupation]) -> HashMap<&'static str, Vec> { + let mut index: HashMap<&'static str, Vec> = HashMap::new(); + for (i, occ) in occupations.iter().enumerate() { + for &kw in &occ.keywords { + index.entry(kw).or_default().push(i); + } + } + index + } + + /// Classify a task instruction into an occupation with estimated value + /// + /// This is a synchronous keyword-based classifier. For LLM-based + /// classification, use `classify_with_llm` instead. + pub fn classify(&self, instruction: &str) -> ClassificationResult { + let lower = instruction.to_lowercase(); + let mut scores: HashMap = HashMap::new(); + + // Score each occupation by keyword matches + for (keyword, occ_indices) in &self.keyword_index { + if lower.contains(keyword) { + for &idx in occ_indices { + *scores.entry(idx).or_default() += 1.0; + } + } + } + + // Find best match + let (best_idx, best_score) = scores + .iter() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(&idx, &score)| (idx, score)) + .unwrap_or((usize::MAX, 0.0)); + + let (occupation, hourly_wage, category, confidence, reasoning) = if best_idx < self.occupations.len() { + let occ = &self.occupations[best_idx]; + let confidence = (best_score / 3.0).min(1.0); // Normalize confidence + ( + occ.name.clone(), + occ.hourly_wage, + occ.category, + confidence, + format!("Matched {} keywords", best_score as i32), + ) + } else { + // Fallback + ( + self.fallback_occupation.clone(), + self.fallback_wage, + OccupationCategory::BusinessFinance, + 0.3, + "Fallback classification - no strong keyword match".to_string(), + ) + }; + + let estimated_hours = Self::estimate_hours(instruction); + let max_payment = (estimated_hours * hourly_wage * 100.0).round() / 100.0; + + ClassificationResult { + occupation, + hourly_wage, + estimated_hours, + max_payment, + confidence, + category, + reasoning, + } + } + + /// Estimate hours based on instruction complexity + fn estimate_hours(instruction: &str) -> f64 { + let word_count = instruction.split_whitespace().count(); + let has_complex_markers = instruction.to_lowercase().contains("implement") + || instruction.contains("build") + || instruction.contains("create") + || instruction.contains("design") + || instruction.contains("develop"); + + let has_simple_markers = instruction.to_lowercase().contains("fix") + || instruction.contains("update") + || instruction.contains("change") + || instruction.contains("review"); + + let base_hours = if has_complex_markers { + 2.0 + } else if has_simple_markers { + 0.5 + } else { + 1.0 + }; + + // Scale by instruction length + let length_factor = (word_count as f64 / 20.0).max(0.5).min(2.0); + let hours = base_hours * length_factor; + + // Clamp to valid range + hours.max(0.25).min(40.0) + } + + /// Get all occupations + pub fn occupations(&self) -> &[Occupation] { + &self.occupations + } + + /// Get occupations by category + pub fn occupations_by_category(&self, category: OccupationCategory) -> Vec<&Occupation> { + self.occupations + .iter() + .filter(|o| o.category == category) + .collect() + } + + /// Get the fallback occupation name + pub fn fallback_occupation(&self) -> &str { + &self.fallback_occupation + } + + /// Get the fallback hourly wage + pub fn fallback_wage(&self) -> f64 { + self.fallback_wage + } + + /// Look up an occupation by exact name + pub fn get_occupation(&self, name: &str) -> Option<&Occupation> { + self.occupations.iter().find(|o| o.name == name) + } + + /// Fuzzy match an occupation name (case-insensitive, substring) + pub fn fuzzy_match(&self, name: &str) -> Option<&Occupation> { + let lower = name.to_lowercase(); + + // Exact match first + if let Some(occ) = self.occupations.iter().find(|o| o.name == name) { + return Some(occ); + } + + // Case-insensitive match + if let Some(occ) = self + .occupations + .iter() + .find(|o| o.name.to_lowercase() == lower) + { + return Some(occ); + } + + // Substring match + self.occupations + .iter() + .find(|o| lower.contains(&o.name.to_lowercase()) || o.name.to_lowercase().contains(&lower)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_classifier_new() { + let classifier = TaskClassifier::new(); + assert_eq!(classifier.occupations.len(), 44); + } + + #[test] + fn test_classify_software() { + let classifier = TaskClassifier::new(); + let result = classifier.classify("Write a REST API in Rust with authentication"); + + assert_eq!(result.occupation, "Software Developers"); + assert!((result.hourly_wage - 69.50).abs() < 0.01); + assert!(result.confidence > 0.0); + } + + #[test] + fn test_classify_finance() { + let classifier = TaskClassifier::new(); + let result = classifier.classify("Prepare quarterly financial statements and audit trail"); + + assert!( + result.occupation.contains("Account") + || result.occupation.contains("Financial"), + "Expected finance occupation, got: {}", + result.occupation + ); + } + + #[test] + fn test_classify_fallback() { + let classifier = TaskClassifier::new(); + let result = classifier.classify("xyzzy foobar baz"); + + assert_eq!(result.occupation, "General and Operations Managers"); + assert_eq!(result.confidence, 0.3); + } + + #[test] + fn test_estimate_hours_complex() { + let hours = TaskClassifier::estimate_hours( + "Implement a complete microservices architecture with event sourcing", + ); + assert!(hours >= 1.0, "Complex task should estimate >= 1 hour"); + } + + #[test] + fn test_estimate_hours_simple() { + let hours = TaskClassifier::estimate_hours("Fix typo"); + assert!(hours <= 1.0, "Simple task should estimate <= 1 hour"); + } + + #[test] + fn test_fuzzy_match() { + let classifier = TaskClassifier::new(); + + // Exact match + assert!(classifier.fuzzy_match("Software Developers").is_some()); + + // Case insensitive + assert!(classifier.fuzzy_match("software developers").is_some()); + + // Substring + assert!(classifier.fuzzy_match("Software").is_some()); + } + + #[test] + fn test_occupations_by_category() { + let classifier = TaskClassifier::new(); + let tech = classifier.occupations_by_category(OccupationCategory::TechnologyEngineering); + + assert!(!tech.is_empty()); + assert!(tech.iter().any(|o| o.name == "Software Developers")); + } +} diff --git a/src/economic/costs.rs b/src/economic/costs.rs new file mode 100644 index 000000000..3d077bc40 --- /dev/null +++ b/src/economic/costs.rs @@ -0,0 +1,369 @@ +//! Token cost tracking types for economic agents. +//! +//! Separates costs by channel (LLM, search API, OCR, etc.) following +//! the ClawWork economic model. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Channel-separated cost breakdown for a task or session. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CostBreakdown { + /// Cost from LLM token usage + pub llm_tokens: f64, + /// Cost from search API calls (Brave, JINA, Tavily, etc.) + pub search_api: f64, + /// Cost from OCR API calls + pub ocr_api: f64, + /// Cost from other API calls + pub other_api: f64, +} + +impl CostBreakdown { + /// Create a new empty cost breakdown. + pub fn new() -> Self { + Self::default() + } + + /// Get total cost across all channels. + pub fn total(&self) -> f64 { + self.llm_tokens + self.search_api + self.ocr_api + self.other_api + } + + /// Add another breakdown to this one. + pub fn add(&mut self, other: &CostBreakdown) { + self.llm_tokens += other.llm_tokens; + self.search_api += other.search_api; + self.ocr_api += other.ocr_api; + self.other_api += other.other_api; + } + + /// Reset all costs to zero. + pub fn reset(&mut self) { + self.llm_tokens = 0.0; + self.search_api = 0.0; + self.ocr_api = 0.0; + self.other_api = 0.0; + } +} + +/// Token pricing configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenPricing { + /// Price per million input tokens (USD) + pub input_price_per_million: f64, + /// Price per million output tokens (USD) + pub output_price_per_million: f64, +} + +impl Default for TokenPricing { + fn default() -> Self { + // Default to Claude Sonnet 4 pricing via OpenRouter + Self { + input_price_per_million: 3.0, + output_price_per_million: 15.0, + } + } +} + +impl TokenPricing { + /// Calculate cost for given token counts. + pub fn calculate_cost(&self, input_tokens: u64, output_tokens: u64) -> f64 { + let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_price_per_million; + let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_price_per_million; + input_cost + output_cost + } +} + +/// A single LLM call record with token details. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmCallRecord { + /// Timestamp of the call + pub timestamp: DateTime, + /// API name/source (e.g., "agent", "wrapup", "research") + pub api_name: String, + /// Number of input tokens + pub input_tokens: u64, + /// Number of output tokens + pub output_tokens: u64, + /// Cost in USD + pub cost: f64, +} + +/// A single API call record (non-LLM). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiCallRecord { + /// Timestamp of the call + pub timestamp: DateTime, + /// API name (e.g., "tavily_search", "jina_reader") + pub api_name: String, + /// Pricing model used + pub pricing_model: PricingModel, + /// Number of tokens (if token-based pricing) + #[serde(skip_serializing_if = "Option::is_none")] + pub tokens: Option, + /// Price per million tokens (if token-based) + #[serde(skip_serializing_if = "Option::is_none")] + pub price_per_million: Option, + /// Cost in USD + pub cost: f64, +} + +/// Pricing model for API calls. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PricingModel { + /// Token-based pricing (cost = tokens / 1M * price_per_million) + PerToken, + /// Flat rate per call + FlatRate, +} + +/// Comprehensive task cost record (one per task). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCostRecord { + /// Task end timestamp + pub timestamp_end: DateTime, + /// Task start timestamp + pub timestamp_start: DateTime, + /// Date the task was assigned (YYYY-MM-DD) + pub date: String, + /// Unique task identifier + pub task_id: String, + /// LLM usage summary + pub llm_usage: LlmUsageSummary, + /// API usage summary + pub api_usage: ApiUsageSummary, + /// Cost summary by channel + pub cost_summary: CostBreakdown, + /// Balance after this task + pub balance_after: f64, + /// Session cost so far + pub session_cost: f64, + /// Daily cost so far + pub daily_cost: f64, +} + +/// Aggregated LLM usage for a task. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LlmUsageSummary { + /// Number of LLM calls made + pub total_calls: usize, + /// Total input tokens + pub total_input_tokens: u64, + /// Total output tokens + pub total_output_tokens: u64, + /// Total tokens (input + output) + pub total_tokens: u64, + /// Total cost in USD + pub total_cost: f64, + /// Pricing used + pub input_price_per_million: f64, + pub output_price_per_million: f64, + /// Detailed call records + #[serde(default)] + pub calls_detail: Vec, +} + +/// Aggregated API usage for a task. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ApiUsageSummary { + /// Number of API calls made + pub total_calls: usize, + /// Search API costs + pub search_api_cost: f64, + /// OCR API costs + pub ocr_api_cost: f64, + /// Other API costs + pub other_api_cost: f64, + /// Number of token-based calls + pub token_based_calls: usize, + /// Number of flat-rate calls + pub flat_rate_calls: usize, + /// Detailed call records + #[serde(default)] + pub calls_detail: Vec, +} + +/// Work income record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkIncomeRecord { + /// Timestamp + pub timestamp: DateTime, + /// Date (YYYY-MM-DD) + pub date: String, + /// Task identifier + pub task_id: String, + /// Base payment amount offered + pub base_amount: f64, + /// Actual payment received (0 if below threshold) + pub actual_payment: f64, + /// Evaluation score (0.0-1.0) + pub evaluation_score: f64, + /// Minimum threshold required for payment + pub threshold: f64, + /// Whether payment was awarded + pub payment_awarded: bool, + /// Optional description + #[serde(default)] + pub description: String, + /// Balance after this income + pub balance_after: f64, +} + +/// Daily balance record for persistence. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BalanceRecord { + /// Date (YYYY-MM-DD or "initialization") + pub date: String, + /// Current balance + pub balance: f64, + /// Token cost delta for this period + pub token_cost_delta: f64, + /// Work income delta for this period + pub work_income_delta: f64, + /// Trading profit delta for this period + pub trading_profit_delta: f64, + /// Cumulative total token cost + pub total_token_cost: f64, + /// Cumulative total work income + pub total_work_income: f64, + /// Cumulative total trading profit + pub total_trading_profit: f64, + /// Net worth (balance + portfolio value) + pub net_worth: f64, + /// Current survival status + pub survival_status: String, + /// Tasks completed in this period + #[serde(default)] + pub completed_tasks: Vec, + /// Primary task ID for the day + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + /// Time to complete tasks (seconds) + #[serde(skip_serializing_if = "Option::is_none")] + pub task_completion_time_seconds: Option, + /// Whether session was aborted by API error + #[serde(default)] + pub api_error: bool, +} + +/// Task completion record for analytics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskCompletionRecord { + /// Task identifier + pub task_id: String, + /// Date (YYYY-MM-DD) + pub date: String, + /// Attempt number (1-based) + pub attempt: u32, + /// Whether work was submitted + pub work_submitted: bool, + /// Evaluation score (0.0-1.0) + pub evaluation_score: f64, + /// Money earned from this task + pub money_earned: f64, + /// Wall-clock time in seconds + pub wall_clock_seconds: f64, + /// Timestamp of completion + pub timestamp: DateTime, +} + +/// Economic analytics summary. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EconomicAnalytics { + /// Total costs by channel + pub total_costs: CostBreakdown, + /// Costs broken down by date + pub by_date: HashMap, + /// Costs broken down by task + pub by_task: HashMap, + /// Total number of tasks + pub total_tasks: usize, + /// Total income earned + pub total_income: f64, + /// Number of tasks that received payment + pub tasks_paid: usize, + /// Number of tasks rejected (below threshold) + pub tasks_rejected: usize, +} + +/// Cost summary for a single date. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DateCostSummary { + /// Costs by channel + #[serde(flatten)] + pub costs: CostBreakdown, + /// Total cost + pub total: f64, + /// Income earned + pub income: f64, +} + +/// Cost summary for a single task. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TaskCostSummary { + /// Costs by channel + #[serde(flatten)] + pub costs: CostBreakdown, + /// Total cost + pub total: f64, + /// Date of the task + pub date: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cost_breakdown_total() { + let breakdown = CostBreakdown { + llm_tokens: 1.0, + search_api: 0.5, + ocr_api: 0.25, + other_api: 0.1, + }; + assert!((breakdown.total() - 1.85).abs() < f64::EPSILON); + } + + #[test] + fn cost_breakdown_add() { + let mut a = CostBreakdown { + llm_tokens: 1.0, + search_api: 0.5, + ocr_api: 0.0, + other_api: 0.0, + }; + let b = CostBreakdown { + llm_tokens: 0.5, + search_api: 0.25, + ocr_api: 0.1, + other_api: 0.05, + }; + a.add(&b); + assert!((a.llm_tokens - 1.5).abs() < f64::EPSILON); + assert!((a.search_api - 0.75).abs() < f64::EPSILON); + assert!((a.total() - 2.4).abs() < f64::EPSILON); + } + + #[test] + fn token_pricing_calculation() { + let pricing = TokenPricing { + input_price_per_million: 3.0, + output_price_per_million: 15.0, + }; + // 1000 input, 500 output + // (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105 + let cost = pricing.calculate_cost(1000, 500); + assert!((cost - 0.0105).abs() < 0.0001); + } + + #[test] + fn default_token_pricing() { + let pricing = TokenPricing::default(); + assert!((pricing.input_price_per_million - 3.0).abs() < f64::EPSILON); + assert!((pricing.output_price_per_million - 15.0).abs() < f64::EPSILON); + } +} diff --git a/src/economic/mod.rs b/src/economic/mod.rs new file mode 100644 index 000000000..73035f761 --- /dev/null +++ b/src/economic/mod.rs @@ -0,0 +1,85 @@ +//! Economic tracking module for agent survival economics. +//! +//! This module implements the ClawWork economic model for AI agents, +//! tracking balance, costs, income, and survival status. Agents start +//! with initial capital and must manage their resources while completing +//! tasks. +//! +//! ## Overview +//! +//! The economic system models agent viability: +//! - **Balance**: Starting capital minus costs plus earned income +//! - **Costs**: LLM tokens, search APIs, OCR, and other service usage +//! - **Income**: Payments for completed tasks (with quality threshold) +//! - **Status**: Health indicator based on remaining capital percentage +//! +//! ## Example +//! +//! ```rust,ignore +//! use zeroclaw::economic::{EconomicTracker, EconomicConfig, SurvivalStatus}; +//! +//! let config = EconomicConfig { +//! enabled: true, +//! initial_balance: 1000.0, +//! ..Default::default() +//! }; +//! +//! let tracker = EconomicTracker::new("my-agent", config, None); +//! tracker.initialize()?; +//! +//! // Start a task +//! tracker.start_task("task-001", None); +//! +//! // Track LLM usage +//! let cost = tracker.track_tokens(1000, 500, "agent", None); +//! +//! // Complete task and earn income +//! tracker.end_task()?; +//! let payment = tracker.add_work_income(10.0, "task-001", 0.85, "Completed task")?; +//! +//! // Check survival status +//! match tracker.get_survival_status() { +//! SurvivalStatus::Thriving => println!("Agent is healthy!"), +//! SurvivalStatus::Bankrupt => println!("Agent needs intervention!"), +//! _ => {} +//! } +//! ``` +//! +//! ## Persistence +//! +//! Economic state is persisted to JSONL files: +//! - `balance.jsonl`: Daily balance snapshots and cumulative totals +//! - `token_costs.jsonl`: Detailed per-task cost records +//! - `task_completions.jsonl`: Task completion statistics +//! +//! ## Configuration +//! +//! Add to `config.toml`: +//! +//! ```toml +//! [economic] +//! enabled = true +//! initial_balance = 1000.0 +//! min_evaluation_threshold = 0.6 +//! +//! [economic.token_pricing] +//! input_price_per_million = 3.0 +//! output_price_per_million = 15.0 +//! ``` + +pub mod classifier; +pub mod costs; +pub mod status; +pub mod tracker; + +// Re-exports for convenient access +pub use costs::{ + ApiCallRecord, ApiUsageSummary, BalanceRecord, CostBreakdown, DateCostSummary, + EconomicAnalytics, LlmCallRecord, LlmUsageSummary, PricingModel, TaskCompletionRecord, + TaskCostRecord, TaskCostSummary, TokenPricing, WorkIncomeRecord, +}; +pub use status::SurvivalStatus; +pub use tracker::{EconomicConfig, EconomicSummary, EconomicTracker}; +pub use classifier::{ + ClassificationResult, Occupation, OccupationCategory, TaskClassifier, +}; diff --git a/src/economic/status.rs b/src/economic/status.rs new file mode 100644 index 000000000..fd83e9ffc --- /dev/null +++ b/src/economic/status.rs @@ -0,0 +1,212 @@ +//! Survival status tracking for economic agents. +//! +//! Defines the health states an agent can be in based on remaining balance +//! as a percentage of initial capital. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Survival status based on balance percentage relative to initial capital. +/// +/// Mirrors the ClawWork LiveBench agent survival states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SurvivalStatus { + /// Balance > 80% of initial - Agent is profitable and healthy + Thriving, + /// Balance 40-80% of initial - Agent is maintaining stability + Stable, + /// Balance 10-40% of initial - Agent is losing money, needs attention + Struggling, + /// Balance 1-10% of initial - Agent is near death, urgent intervention needed + Critical, + /// Balance <= 0 - Agent has exhausted resources and cannot operate + Bankrupt, +} + +impl SurvivalStatus { + /// Calculate survival status from current and initial balance. + /// + /// # Arguments + /// * `current_balance` - Current remaining balance + /// * `initial_balance` - Starting balance + /// + /// # Returns + /// The appropriate `SurvivalStatus` based on the percentage remaining. + pub fn from_balance(current_balance: f64, initial_balance: f64) -> Self { + if initial_balance <= 0.0 { + // Edge case: if initial was zero or negative, can't calculate percentage + return if current_balance <= 0.0 { + Self::Bankrupt + } else { + Self::Thriving + }; + } + + let percentage = (current_balance / initial_balance) * 100.0; + + match percentage { + p if p <= 0.0 => Self::Bankrupt, + p if p < 10.0 => Self::Critical, + p if p < 40.0 => Self::Struggling, + p if p < 80.0 => Self::Stable, + _ => Self::Thriving, + } + } + + /// Check if the agent can still operate (not bankrupt). + pub fn is_operational(&self) -> bool { + !matches!(self, Self::Bankrupt) + } + + /// Check if the agent needs urgent attention. + pub fn needs_intervention(&self) -> bool { + matches!(self, Self::Critical | Self::Bankrupt) + } + + /// Get a human-readable emoji indicator. + pub fn emoji(&self) -> &'static str { + match self { + Self::Thriving => "🌟", + Self::Stable => "✅", + Self::Struggling => "⚠️", + Self::Critical => "🚨", + Self::Bankrupt => "💀", + } + } + + /// Get a color code for terminal output (ANSI). + pub fn ansi_color(&self) -> &'static str { + match self { + Self::Thriving => "\x1b[32m", // Green + Self::Stable => "\x1b[34m", // Blue + Self::Struggling => "\x1b[33m", // Yellow + Self::Critical => "\x1b[31m", // Red + Self::Bankrupt => "\x1b[35m", // Magenta + } + } +} + +impl fmt::Display for SurvivalStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let status = match self { + Self::Thriving => "Thriving", + Self::Stable => "Stable", + Self::Struggling => "Struggling", + Self::Critical => "Critical", + Self::Bankrupt => "Bankrupt", + }; + write!(f, "{}", status) + } +} + +impl Default for SurvivalStatus { + fn default() -> Self { + Self::Stable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thriving_above_80_percent() { + assert_eq!( + SurvivalStatus::from_balance(900.0, 1000.0), + SurvivalStatus::Thriving + ); + assert_eq!( + SurvivalStatus::from_balance(1500.0, 1000.0), // Profit! + SurvivalStatus::Thriving + ); + assert_eq!( + SurvivalStatus::from_balance(800.01, 1000.0), + SurvivalStatus::Thriving + ); + } + + #[test] + fn stable_between_40_and_80_percent() { + assert_eq!( + SurvivalStatus::from_balance(799.99, 1000.0), + SurvivalStatus::Stable + ); + assert_eq!( + SurvivalStatus::from_balance(500.0, 1000.0), + SurvivalStatus::Stable + ); + assert_eq!( + SurvivalStatus::from_balance(400.01, 1000.0), + SurvivalStatus::Stable + ); + } + + #[test] + fn struggling_between_10_and_40_percent() { + assert_eq!( + SurvivalStatus::from_balance(399.99, 1000.0), + SurvivalStatus::Struggling + ); + assert_eq!( + SurvivalStatus::from_balance(200.0, 1000.0), + SurvivalStatus::Struggling + ); + assert_eq!( + SurvivalStatus::from_balance(100.01, 1000.0), + SurvivalStatus::Struggling + ); + } + + #[test] + fn critical_between_0_and_10_percent() { + assert_eq!( + SurvivalStatus::from_balance(99.99, 1000.0), + SurvivalStatus::Critical + ); + assert_eq!( + SurvivalStatus::from_balance(50.0, 1000.0), + SurvivalStatus::Critical + ); + assert_eq!( + SurvivalStatus::from_balance(0.01, 1000.0), + SurvivalStatus::Critical + ); + } + + #[test] + fn bankrupt_at_zero_or_negative() { + assert_eq!( + SurvivalStatus::from_balance(0.0, 1000.0), + SurvivalStatus::Bankrupt + ); + assert_eq!( + SurvivalStatus::from_balance(-100.0, 1000.0), + SurvivalStatus::Bankrupt + ); + } + + #[test] + fn is_operational() { + assert!(SurvivalStatus::Thriving.is_operational()); + assert!(SurvivalStatus::Stable.is_operational()); + assert!(SurvivalStatus::Struggling.is_operational()); + assert!(SurvivalStatus::Critical.is_operational()); + assert!(!SurvivalStatus::Bankrupt.is_operational()); + } + + #[test] + fn needs_intervention() { + assert!(!SurvivalStatus::Thriving.needs_intervention()); + assert!(!SurvivalStatus::Stable.needs_intervention()); + assert!(!SurvivalStatus::Struggling.needs_intervention()); + assert!(SurvivalStatus::Critical.needs_intervention()); + assert!(SurvivalStatus::Bankrupt.needs_intervention()); + } + + #[test] + fn display_format() { + assert_eq!(format!("{}", SurvivalStatus::Thriving), "Thriving"); + assert_eq!(format!("{}", SurvivalStatus::Bankrupt), "Bankrupt"); + } +} diff --git a/src/economic/tracker.rs b/src/economic/tracker.rs new file mode 100644 index 000000000..0426a86a3 --- /dev/null +++ b/src/economic/tracker.rs @@ -0,0 +1,995 @@ +//! Economic tracker for agent survival economics. +//! +//! Tracks balance, token costs, work income, and survival status following +//! the ClawWork LiveBench economic model. Persists state to JSONL files. + +use super::costs::{ + ApiCallRecord, BalanceRecord, CostBreakdown, LlmCallRecord, LlmUsageSummary, + ApiUsageSummary, PricingModel, TaskCompletionRecord, TaskCostRecord, TokenPricing, + WorkIncomeRecord, +}; +use super::status::SurvivalStatus; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::sync::Arc; + +/// Economic configuration options. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EconomicConfig { + /// Enable economic tracking + #[serde(default)] + pub enabled: bool, + /// Starting balance in USD + #[serde(default = "default_initial_balance")] + pub initial_balance: f64, + /// Token pricing configuration + #[serde(default)] + pub token_pricing: TokenPricing, + /// Minimum evaluation score to receive payment (0.0-1.0) + #[serde(default = "default_min_threshold")] + pub min_evaluation_threshold: f64, +} + +fn default_initial_balance() -> f64 { + 1000.0 +} + +fn default_min_threshold() -> f64 { + 0.6 +} + +impl Default for EconomicConfig { + fn default() -> Self { + Self { + enabled: false, + initial_balance: default_initial_balance(), + token_pricing: TokenPricing::default(), + min_evaluation_threshold: default_min_threshold(), + } + } +} + +/// Task-level tracking state (in-memory during task execution). +#[derive(Debug, Clone, Default)] +struct TaskState { + /// Current task ID + task_id: Option, + /// Date the task was assigned + task_date: Option, + /// Task start timestamp + start_time: Option>, + /// Costs accumulated for this task + costs: CostBreakdown, + /// LLM call records + llm_calls: Vec, + /// API call records + api_calls: Vec, +} + +impl TaskState { + fn reset(&mut self) { + self.task_id = None; + self.task_date = None; + self.start_time = None; + self.costs.reset(); + self.llm_calls.clear(); + self.api_calls.clear(); + } +} + +/// Daily tracking state (accumulated across tasks). +#[derive(Debug, Clone, Default)] +struct DailyState { + /// Task IDs completed today + task_ids: Vec, + /// First task start time + first_task_start: Option>, + /// Last task end time + last_task_end: Option>, + /// Daily cost accumulator + cost: f64, +} + +impl DailyState { + fn reset(&mut self) { + self.task_ids.clear(); + self.first_task_start = None; + self.last_task_end = None; + self.cost = 0.0; + } +} + +/// Session tracking state. +#[derive(Debug, Clone, Default)] +struct SessionState { + /// Input tokens this session + input_tokens: u64, + /// Output tokens this session + output_tokens: u64, + /// Cost this session + cost: f64, +} + +impl SessionState { + fn reset(&mut self) { + self.input_tokens = 0; + self.output_tokens = 0; + self.cost = 0.0; + } +} + +/// Economic tracker for managing agent survival economics. +/// +/// Tracks: +/// - Balance (starting capital minus costs plus income) +/// - Token costs separated by channel (LLM, search, OCR, etc.) +/// - Work income with evaluation threshold +/// - Trading profits/losses +/// - Survival status +/// +/// Persists records to JSONL files for durability and analysis. +pub struct EconomicTracker { + /// Configuration + config: EconomicConfig, + /// Agent signature/name + signature: String, + /// Data directory for persistence + data_path: PathBuf, + /// Current balance (protected by mutex for thread safety) + state: Arc>, +} + +/// Internal mutable state. +struct TrackerState { + /// Current balance + balance: f64, + /// Initial balance (for status calculation) + initial_balance: f64, + /// Cumulative totals + total_token_cost: f64, + total_work_income: f64, + total_trading_profit: f64, + /// Task-level tracking + task: TaskState, + /// Daily tracking + daily: DailyState, + /// Session tracking + session: SessionState, +} + +impl EconomicTracker { + /// Create a new economic tracker. + /// + /// # Arguments + /// * `signature` - Agent signature/name for identification + /// * `config` - Economic configuration + /// * `data_path` - Optional custom data path (defaults to `./data/agent_data/{signature}/economic`) + pub fn new( + signature: impl Into, + config: EconomicConfig, + data_path: Option, + ) -> Self { + let signature = signature.into(); + let data_path = data_path.unwrap_or_else(|| { + PathBuf::from(format!("./data/agent_data/{}/economic", signature)) + }); + + Self { + signature, + state: Arc::new(Mutex::new(TrackerState { + balance: config.initial_balance, + initial_balance: config.initial_balance, + total_token_cost: 0.0, + total_work_income: 0.0, + total_trading_profit: 0.0, + task: TaskState::default(), + daily: DailyState::default(), + session: SessionState::default(), + })), + config, + data_path, + } + } + + /// Initialize the tracker, loading existing state or creating new. + pub fn initialize(&self) -> Result<()> { + fs::create_dir_all(&self.data_path).with_context(|| { + format!("Failed to create data directory: {}", self.data_path.display()) + })?; + + let balance_file = self.balance_file_path(); + + if balance_file.exists() { + self.load_latest_state()?; + let state = self.state.lock(); + tracing::info!( + "📊 Loaded economic state for {}: balance=${:.2}, status={}", + self.signature, + state.balance, + self.get_survival_status_inner(&state) + ); + } else { + self.save_balance_record( + "initialization", + 0.0, + 0.0, + 0.0, + Vec::new(), + false, + )?; + tracing::info!( + "✅ Initialized economic tracker for {}: starting balance=${:.2}", + self.signature, + self.config.initial_balance + ); + } + + Ok(()) + } + + /// Start tracking costs for a new task. + pub fn start_task(&self, task_id: impl Into, date: Option) { + let task_id = task_id.into(); + let date = date.unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()); + let now = Utc::now(); + + let mut state = self.state.lock(); + state.task.task_id = Some(task_id.clone()); + state.task.task_date = Some(date); + state.task.start_time = Some(now); + state.task.costs.reset(); + state.task.llm_calls.clear(); + state.task.api_calls.clear(); + + // Track daily window + if state.daily.first_task_start.is_none() { + state.daily.first_task_start = Some(now); + } + state.daily.task_ids.push(task_id); + } + + /// End tracking for current task and save consolidated record. + pub fn end_task(&self) -> Result<()> { + let mut state = self.state.lock(); + + if state.task.task_id.is_some() { + self.save_task_record_inner(&state)?; + state.daily.last_task_end = Some(Utc::now()); + state.task.reset(); + } + + Ok(()) + } + + /// Track LLM token usage. + /// + /// # Arguments + /// * `input_tokens` - Number of input tokens + /// * `output_tokens` - Number of output tokens + /// * `api_name` - Origin of the call (e.g., "agent", "wrapup") + /// * `cost` - Pre-computed cost (if provided, skips local calculation) + /// + /// # Returns + /// The cost in USD for this call. + pub fn track_tokens( + &self, + input_tokens: u64, + output_tokens: u64, + api_name: impl Into, + cost: Option, + ) -> f64 { + let api_name = api_name.into(); + let cost = cost.unwrap_or_else(|| { + self.config.token_pricing.calculate_cost(input_tokens, output_tokens) + }); + + let mut state = self.state.lock(); + + // Update session tracking + state.session.input_tokens += input_tokens; + state.session.output_tokens += output_tokens; + state.session.cost += cost; + state.daily.cost += cost; + + // Update task-level tracking + state.task.costs.llm_tokens += cost; + state.task.llm_calls.push(LlmCallRecord { + timestamp: Utc::now(), + api_name, + input_tokens, + output_tokens, + cost, + }); + + // Update totals + state.total_token_cost += cost; + state.balance -= cost; + + cost + } + + /// Track token-based API call cost. + /// + /// # Arguments + /// * `tokens` - Number of tokens used + /// * `price_per_million` - Price per million tokens + /// * `api_name` - Name of the API + /// + /// # Returns + /// The cost in USD for this call. + pub fn track_api_call( + &self, + tokens: u64, + price_per_million: f64, + api_name: impl Into, + ) -> f64 { + let api_name = api_name.into(); + let cost = (tokens as f64 / 1_000_000.0) * price_per_million; + + self.record_api_cost(&api_name, cost, Some(tokens), Some(price_per_million), PricingModel::PerToken); + + cost + } + + /// Track flat-rate API call cost. + /// + /// # Arguments + /// * `cost` - Flat cost in USD + /// * `api_name` - Name of the API + /// + /// # Returns + /// The cost (same as input). + pub fn track_flat_api_call(&self, cost: f64, api_name: impl Into) -> f64 { + let api_name = api_name.into(); + self.record_api_cost(&api_name, cost, None, None, PricingModel::FlatRate); + cost + } + + fn record_api_cost( + &self, + api_name: &str, + cost: f64, + tokens: Option, + price_per_million: Option, + pricing_model: PricingModel, + ) { + let mut state = self.state.lock(); + + // Update session/daily + state.session.cost += cost; + state.daily.cost += cost; + + // Categorize by API type + let api_lower = api_name.to_lowercase(); + if api_lower.contains("search") || api_lower.contains("jina") || api_lower.contains("tavily") { + state.task.costs.search_api += cost; + } else if api_lower.contains("ocr") { + state.task.costs.ocr_api += cost; + } else { + state.task.costs.other_api += cost; + } + + // Record detailed call + state.task.api_calls.push(ApiCallRecord { + timestamp: Utc::now(), + api_name: api_name.to_string(), + pricing_model, + tokens, + price_per_million, + cost, + }); + + // Update totals + state.total_token_cost += cost; + state.balance -= cost; + } + + /// Add income from completed work with evaluation threshold. + /// + /// Payment is only awarded if `evaluation_score >= min_evaluation_threshold`. + /// + /// # Arguments + /// * `amount` - Base payment amount in USD + /// * `task_id` - Task identifier + /// * `evaluation_score` - Score from 0.0 to 1.0 + /// * `description` - Optional description + /// + /// # Returns + /// Actual payment received (0.0 if below threshold). + pub fn add_work_income( + &self, + amount: f64, + task_id: impl Into, + evaluation_score: f64, + description: impl Into, + ) -> Result { + let task_id = task_id.into(); + let description = description.into(); + let threshold = self.config.min_evaluation_threshold; + + let actual_payment = if evaluation_score >= threshold { + amount + } else { + 0.0 + }; + + { + let mut state = self.state.lock(); + if actual_payment > 0.0 { + state.balance += actual_payment; + state.total_work_income += actual_payment; + tracing::info!( + "💰 Work income: +${:.2} (Task: {}, Score: {:.2})", + actual_payment, + task_id, + evaluation_score + ); + } else { + tracing::warn!( + "⚠️ Work below threshold (score: {:.2} < {:.2}), no payment for task: {}", + evaluation_score, + threshold, + task_id + ); + } + } + + self.log_work_income( + &task_id, + amount, + actual_payment, + evaluation_score, + &description, + )?; + + Ok(actual_payment) + } + + /// Add profit/loss from trading. + pub fn add_trading_profit(&self, profit: f64, _description: impl Into) { + let mut state = self.state.lock(); + state.balance += profit; + state.total_trading_profit += profit; + + let sign = if profit >= 0.0 { "+" } else { "" }; + tracing::info!( + "📈 Trading P&L: {}${:.2}, new balance: ${:.2}", + sign, + profit, + state.balance + ); + } + + /// Save end-of-day economic state. + pub fn save_daily_state( + &self, + date: &str, + work_income: f64, + trading_profit: f64, + completed_tasks: Vec, + api_error: bool, + ) -> Result<()> { + let daily_cost = { + let state = self.state.lock(); + state.daily.cost + }; + + self.save_balance_record( + date, + daily_cost, + work_income, + trading_profit, + completed_tasks, + api_error, + )?; + + // Reset daily tracking + { + let mut state = self.state.lock(); + state.daily.reset(); + state.session.reset(); + } + + tracing::info!("💾 Saved daily state for {}", date); + + Ok(()) + } + + /// Get current balance. + pub fn get_balance(&self) -> f64 { + self.state.lock().balance + } + + /// Get net worth (balance + portfolio value). + pub fn get_net_worth(&self) -> f64 { + // TODO: Add trading portfolio value + self.get_balance() + } + + /// Get current survival status. + pub fn get_survival_status(&self) -> SurvivalStatus { + let state = self.state.lock(); + self.get_survival_status_inner(&state) + } + + fn get_survival_status_inner(&self, state: &TrackerState) -> SurvivalStatus { + SurvivalStatus::from_balance(state.balance, state.initial_balance) + } + + /// Check if agent is bankrupt. + pub fn is_bankrupt(&self) -> bool { + self.get_survival_status() == SurvivalStatus::Bankrupt + } + + /// Get session cost so far. + pub fn get_session_cost(&self) -> f64 { + self.state.lock().session.cost + } + + /// Get daily cost so far. + pub fn get_daily_cost(&self) -> f64 { + self.state.lock().daily.cost + } + + /// Get comprehensive economic summary. + pub fn get_summary(&self) -> EconomicSummary { + let state = self.state.lock(); + EconomicSummary { + signature: self.signature.clone(), + balance: state.balance, + initial_balance: state.initial_balance, + net_worth: state.balance, // TODO: Add portfolio + total_token_cost: state.total_token_cost, + total_work_income: state.total_work_income, + total_trading_profit: state.total_trading_profit, + session_cost: state.session.cost, + daily_cost: state.daily.cost, + session_input_tokens: state.session.input_tokens, + session_output_tokens: state.session.output_tokens, + survival_status: self.get_survival_status_inner(&state), + is_bankrupt: self.get_survival_status_inner(&state) == SurvivalStatus::Bankrupt, + min_evaluation_threshold: self.config.min_evaluation_threshold, + } + } + + /// Reset session tracking (for new decision/activity). + pub fn reset_session(&self) { + self.state.lock().session.reset(); + } + + /// Record task completion statistics. + pub fn record_task_completion( + &self, + task_id: impl Into, + work_submitted: bool, + wall_clock_seconds: f64, + evaluation_score: f64, + money_earned: f64, + attempt: u32, + date: Option, + ) -> Result<()> { + let task_id = task_id.into(); + let date = date.or_else(|| { + self.state.lock().task.task_date.clone() + }).unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()); + + let record = TaskCompletionRecord { + task_id: task_id.clone(), + date, + attempt, + work_submitted, + evaluation_score, + money_earned, + wall_clock_seconds, + timestamp: Utc::now(), + }; + + // Read existing records, filter out this task_id + let completions_file = self.task_completions_file_path(); + let mut existing: Vec = Vec::new(); + + if completions_file.exists() { + let file = File::open(&completions_file)?; + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if let Ok(entry) = serde_json::from_str::(&line) { + if entry.task_id != task_id { + existing.push(line); + } + } else { + existing.push(line); + } + } + } + + // Rewrite with updated record + let mut file = File::create(&completions_file)?; + for line in existing { + writeln!(file, "{}", line)?; + } + writeln!(file, "{}", serde_json::to_string(&record)?)?; + file.sync_all()?; + + Ok(()) + } + + // ── Private helpers ── + + fn balance_file_path(&self) -> PathBuf { + self.data_path.join("balance.jsonl") + } + + fn token_costs_file_path(&self) -> PathBuf { + self.data_path.join("token_costs.jsonl") + } + + fn task_completions_file_path(&self) -> PathBuf { + self.data_path.join("task_completions.jsonl") + } + + fn load_latest_state(&self) -> Result<()> { + let balance_file = self.balance_file_path(); + let file = File::open(&balance_file)?; + let reader = BufReader::new(file); + + let mut last_record: Option = None; + for line in reader.lines() { + let line = line?; + if let Ok(record) = serde_json::from_str::(&line) { + last_record = Some(record); + } + } + + if let Some(record) = last_record { + let mut state = self.state.lock(); + state.balance = record.balance; + state.total_token_cost = record.total_token_cost; + state.total_work_income = record.total_work_income; + state.total_trading_profit = record.total_trading_profit; + } + + Ok(()) + } + + fn save_task_record_inner(&self, state: &TrackerState) -> Result<()> { + let Some(ref task_id) = state.task.task_id else { + return Ok(()); + }; + + let total_input = state.task.llm_calls.iter().map(|c| c.input_tokens).sum(); + let total_output = state.task.llm_calls.iter().map(|c| c.output_tokens).sum(); + let llm_call_count = state.task.llm_calls.len(); + + let token_based = state.task.api_calls.iter() + .filter(|c| c.pricing_model == PricingModel::PerToken) + .count(); + let flat_rate = state.task.api_calls.iter() + .filter(|c| c.pricing_model == PricingModel::FlatRate) + .count(); + + let record = TaskCostRecord { + timestamp_end: Utc::now(), + timestamp_start: state.task.start_time.unwrap_or_else(Utc::now), + date: state.task.task_date.clone().unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()), + task_id: task_id.clone(), + llm_usage: LlmUsageSummary { + total_calls: llm_call_count, + total_input_tokens: total_input, + total_output_tokens: total_output, + total_tokens: total_input + total_output, + total_cost: state.task.costs.llm_tokens, + input_price_per_million: self.config.token_pricing.input_price_per_million, + output_price_per_million: self.config.token_pricing.output_price_per_million, + calls_detail: state.task.llm_calls.clone(), + }, + api_usage: ApiUsageSummary { + total_calls: state.task.api_calls.len(), + search_api_cost: state.task.costs.search_api, + ocr_api_cost: state.task.costs.ocr_api, + other_api_cost: state.task.costs.other_api, + token_based_calls: token_based, + flat_rate_calls: flat_rate, + calls_detail: state.task.api_calls.clone(), + }, + cost_summary: state.task.costs.clone(), + balance_after: state.balance, + session_cost: state.session.cost, + daily_cost: state.daily.cost, + }; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(self.token_costs_file_path())?; + writeln!(file, "{}", serde_json::to_string(&record)?)?; + file.sync_all()?; + + Ok(()) + } + + fn save_balance_record( + &self, + date: &str, + token_cost_delta: f64, + work_income_delta: f64, + trading_profit_delta: f64, + completed_tasks: Vec, + api_error: bool, + ) -> Result<()> { + let state = self.state.lock(); + + let task_completion_time = match (state.daily.first_task_start, state.daily.last_task_end) { + (Some(start), Some(end)) => Some((end - start).num_seconds() as f64), + _ => None, + }; + + let record = BalanceRecord { + date: date.to_string(), + balance: state.balance, + token_cost_delta, + work_income_delta, + trading_profit_delta, + total_token_cost: state.total_token_cost, + total_work_income: state.total_work_income, + total_trading_profit: state.total_trading_profit, + net_worth: state.balance, + survival_status: self.get_survival_status_inner(&state).to_string(), + completed_tasks, + task_id: state.daily.task_ids.first().cloned(), + task_completion_time_seconds: task_completion_time, + api_error, + }; + + drop(state); // Release lock before IO + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(self.balance_file_path())?; + writeln!(file, "{}", serde_json::to_string(&record)?)?; + file.sync_all()?; + + Ok(()) + } + + fn log_work_income( + &self, + task_id: &str, + base_amount: f64, + actual_payment: f64, + evaluation_score: f64, + description: &str, + ) -> Result<()> { + let state = self.state.lock(); + + let record = WorkIncomeRecord { + timestamp: Utc::now(), + date: state.task.task_date.clone() + .unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string()), + task_id: task_id.to_string(), + base_amount, + actual_payment, + evaluation_score, + threshold: self.config.min_evaluation_threshold, + payment_awarded: actual_payment > 0.0, + description: description.to_string(), + balance_after: state.balance, + }; + + drop(state); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(self.token_costs_file_path())?; + writeln!(file, "{}", serde_json::to_string(&record)?)?; + file.sync_all()?; + + Ok(()) + } +} + +impl std::fmt::Display for EconomicTracker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = self.state.lock(); + write!( + f, + "EconomicTracker(signature='{}', balance=${:.2}, status={})", + self.signature, + state.balance, + self.get_survival_status_inner(&state) + ) + } +} + +/// Comprehensive economic summary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EconomicSummary { + pub signature: String, + pub balance: f64, + pub initial_balance: f64, + pub net_worth: f64, + pub total_token_cost: f64, + pub total_work_income: f64, + pub total_trading_profit: f64, + pub session_cost: f64, + pub daily_cost: f64, + pub session_input_tokens: u64, + pub session_output_tokens: u64, + pub survival_status: SurvivalStatus, + pub is_bankrupt: bool, + pub min_evaluation_threshold: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config() -> EconomicConfig { + EconomicConfig { + enabled: true, + initial_balance: 1000.0, + token_pricing: TokenPricing { + input_price_per_million: 3.0, + output_price_per_million: 15.0, + }, + min_evaluation_threshold: 0.6, + } + } + + #[test] + fn tracker_initialization() { + let tmp = TempDir::new().unwrap(); + let config = test_config(); + let tracker = EconomicTracker::new( + "test-agent", + config, + Some(tmp.path().to_path_buf()), + ); + + tracker.initialize().unwrap(); + + assert!((tracker.get_balance() - 1000.0).abs() < f64::EPSILON); + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Thriving); + } + + #[test] + fn track_tokens_reduces_balance() { + let tmp = TempDir::new().unwrap(); + let tracker = EconomicTracker::new( + "test-agent", + test_config(), + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + + tracker.start_task("task-1", None); + let cost = tracker.track_tokens(1000, 500, "agent", None); + tracker.end_task().unwrap(); + + // (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105 + assert!((cost - 0.0105).abs() < 0.0001); + assert!((tracker.get_balance() - (1000.0 - 0.0105)).abs() < 0.0001); + } + + #[test] + fn work_income_with_threshold() { + let tmp = TempDir::new().unwrap(); + let tracker = EconomicTracker::new( + "test-agent", + test_config(), + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + + // Below threshold - no payment + let payment = tracker.add_work_income(100.0, "task-1", 0.5, "").unwrap(); + assert!((payment - 0.0).abs() < f64::EPSILON); + assert!((tracker.get_balance() - 1000.0).abs() < f64::EPSILON); + + // At threshold - payment awarded + let payment = tracker.add_work_income(100.0, "task-2", 0.6, "").unwrap(); + assert!((payment - 100.0).abs() < f64::EPSILON); + assert!((tracker.get_balance() - 1100.0).abs() < f64::EPSILON); + } + + #[test] + fn survival_status_changes() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(); + config.initial_balance = 100.0; + + let tracker = EconomicTracker::new( + "test-agent", + config, + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Thriving); + + // Spend 30% - should be stable + tracker.track_tokens(10_000_000, 0, "agent", Some(30.0)); + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Stable); + + // Spend more to reach struggling + tracker.track_tokens(10_000_000, 0, "agent", Some(35.0)); + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Struggling); + + // Spend more to reach critical + tracker.track_tokens(10_000_000, 0, "agent", Some(25.0)); + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Critical); + + // Bankrupt + tracker.track_tokens(10_000_000, 0, "agent", Some(20.0)); + assert_eq!(tracker.get_survival_status(), SurvivalStatus::Bankrupt); + assert!(tracker.is_bankrupt()); + } + + #[test] + fn state_persistence() { + let tmp = TempDir::new().unwrap(); + let config = test_config(); + + // Create tracker, do some work, save state + { + let tracker = EconomicTracker::new( + "test-agent", + config.clone(), + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + tracker.track_tokens(1000, 500, "agent", Some(10.0)); + tracker.save_daily_state("2025-01-01", 0.0, 0.0, vec![], false).unwrap(); + } + + // Create new tracker, should load state + { + let tracker = EconomicTracker::new( + "test-agent", + config, + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + assert!((tracker.get_balance() - 990.0).abs() < 0.01); + } + } + + #[test] + fn api_call_categorization() { + let tmp = TempDir::new().unwrap(); + let tracker = EconomicTracker::new( + "test-agent", + test_config(), + Some(tmp.path().to_path_buf()), + ); + tracker.initialize().unwrap(); + + tracker.start_task("task-1", None); + + // Search API + tracker.track_flat_api_call(0.001, "tavily_search"); + + // OCR API + tracker.track_api_call(1000, 1.0, "ocr_reader"); + + // Other API + tracker.track_flat_api_call(0.01, "some_api"); + + tracker.end_task().unwrap(); + + // Balance should reflect all costs + let expected_reduction = 0.001 + 0.001 + 0.01; // search + ocr + other + assert!((tracker.get_balance() - (1000.0 - expected_reduction)).abs() < 0.0001); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5a8be0779..8e2f8817c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ pub mod config; pub mod coordination; pub(crate) mod cost; pub(crate) mod cron; +pub mod economic; pub(crate) mod daemon; pub(crate) mod doctor; pub mod gateway; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 2d1d2f099..6a9c089b8 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -186,6 +186,7 @@ pub async fn run_wizard(force: bool) -> Result { proxy: crate::config::ProxyConfig::default(), identity: identity_config, cost: crate::config::CostConfig::default(), + economic: crate::config::EconomicConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hooks: crate::config::HooksConfig::default(), @@ -550,6 +551,7 @@ async fn run_quick_setup_with_home( proxy: crate::config::ProxyConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), + economic: crate::config::EconomicConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hooks: crate::config::HooksConfig::default(),