Compare commits

...

7 Commits

Author SHA1 Message Date
Fam Zheng
9d2d2af33f add context.md, strip LLM timestamps, clippy fixes, simplify deploy target 2026-04-10 22:43:52 +01:00
Fam Zheng
c0e12798ee add run_shell and run_python tools, deploy-suite target 2026-04-10 21:47:14 +01:00
Fam Zheng
8a5b65f128 doc: merge Worker+Self into Life Loop, sync suite.md with code 2026-04-10 21:33:30 +01:00
Fam Zheng
f646391f14 extract Output trait: decouple AI core from Telegram
- Add src/output.rs with Output trait and 3 implementations:
  TelegramOutput (streaming via draft/edit), GiteaOutput (comments),
  BufferOutput (for worker/tests)
- Refactor run_openai_with_tools and execute_tool to use &mut dyn Output
- Remove run_claude_streaming, invoke_claude_streaming, run_openai_streaming
  (dead code — only OpenAI-compatible backend is used now)
- Remove BackendConfig::Claude code path from handler
- stream.rs: 790 → 150 lines
2026-04-10 21:10:36 +01:00
Fam Zheng
dbd729ecb8 add Gitea Bot interface: webhook server, API tool, Caddy ingress
- Add src/gitea.rs: axum webhook server on :9800, handles @mention in
  issues and PRs, spawns claude -p for review, posts result as comment
- Add call_gitea_api tool: LLM can directly call Gitea REST API with
  pre-configured admin token (noc_bot identity)
- Add Caddy to Docker image as ingress layer (subdomain/path routing)
- Config: add gitea section with token_file support for auto-provisioned token
- Update suite.md: VPS-first deployment, SubAgent architecture, Caddy role
2026-04-10 21:09:15 +01:00
Fam Zheng
035d9b9be2 add suite: all-in-one Docker image with noc + Gitea
- Switch to rustls (drop OpenSSL dependency) for musl static build
- Add deploy/ with Dockerfile and entrypoint (Gitea auto-setup + admin token)
- Add Makefile targets: build-musl, docker
- Add doc/suite.md: design doc for human-AI collaboration interfaces
2026-04-10 21:09:15 +01:00
Fam Zheng
b093b96a46 add life_log, memory_slots updated_at, enhanced reflection and system prompt 2026-04-10 21:09:04 +01:00
18 changed files with 1838 additions and 998 deletions

1
.gitignore vendored
View File

@@ -5,4 +5,5 @@ state.*.json
*.db *.db
target/ target/
data/
noc.service noc.service

603
Cargo.lock generated
View File

@@ -51,6 +51,17 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -63,6 +74,58 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper 1.0.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -115,6 +178,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -145,16 +214,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -294,12 +353,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -318,21 +371,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -446,8 +484,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -458,7 +512,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@@ -482,25 +536,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -623,7 +658,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.3.27", "h2",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
@@ -647,10 +682,10 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.13",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -658,6 +693,20 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.7" version = "0.27.7"
@@ -667,40 +716,12 @@ dependencies = [
"http 1.4.0", "http 1.4.0",
"hyper 1.9.0", "hyper 1.9.0",
"hyper-util", "hyper-util",
"rustls", "rustls 0.23.37",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.26.4",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper 0.14.32",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.9.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service", "tower-service",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -721,11 +742,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.3", "socket2 0.6.3",
"system-configuration 0.7.0",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -951,12 +970,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -978,6 +991,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -987,6 +1006,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -1020,23 +1045,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "never" name = "never"
version = "0.1.0" version = "0.1.0"
@@ -1048,6 +1056,8 @@ name = "noc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"cron", "cron",
@@ -1090,50 +1100,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1180,7 +1146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared",
"rand", "rand 0.8.5",
] ]
[[package]] [[package]]
@@ -1246,6 +1212,15 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -1308,6 +1283,61 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.37",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1317,6 +1347,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@@ -1329,7 +1365,27 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1338,6 +1394,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rc-box" name = "rc-box"
version = "1.3.0" version = "1.3.0"
@@ -1384,28 +1449,28 @@ dependencies = [
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.3.27", "h2",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"hyper 0.14.32", "hyper 0.14.32",
"hyper-tls 0.5.0", "hyper-rustls 0.24.2",
"ipnet", "ipnet",
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess", "mime_guess",
"native-tls",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls 0.21.12",
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper 0.1.2", "sync_wrapper 0.1.2",
"system-configuration 0.5.1", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls 0.24.1",
"tokio-util", "tokio-util",
"tower-service", "tower-service",
"url", "url",
@@ -1413,6 +1478,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots 0.25.4",
"winreg", "winreg",
] ]
@@ -1424,31 +1490,28 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.4.13",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.9.0", "hyper 1.9.0",
"hyper-rustls", "hyper-rustls 0.27.7",
"hyper-tls 0.6.0",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess", "mime_guess",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper 1.0.2", "sync_wrapper 1.0.2",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls 0.26.4",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
@@ -1456,6 +1519,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -1486,6 +1550,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1496,16 +1566,15 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustix" name = "rustls"
version = "1.1.4" version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [ dependencies = [
"bitflags 2.11.0", "log",
"errno", "ring",
"libc", "rustls-webpki 0.101.7",
"linux-raw-sys", "sct",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1515,8 +1584,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki 0.103.10",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -1536,9 +1606,20 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.10" version = "0.103.10"
@@ -1562,15 +1643,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1578,26 +1650,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "security-framework" name = "sct"
version = "3.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [ dependencies = [
"bitflags 2.11.0", "ring",
"core-foundation 0.10.1", "untrusted",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
] ]
[[package]] [[package]]
@@ -1649,6 +1708,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1828,19 +1898,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation",
"system-configuration-sys 0.5.0", "system-configuration-sys",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys 0.6.0",
] ]
[[package]] [[package]]
@@ -1853,16 +1912,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "take_mut" name = "take_mut"
version = "0.2.2" version = "0.2.2"
@@ -1894,7 +1943,7 @@ dependencies = [
"serde_with_macros", "serde_with_macros",
"teloxide-core", "teloxide-core",
"teloxide-macros", "teloxide-macros",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
@@ -1925,7 +1974,7 @@ dependencies = [
"serde_with_macros", "serde_with_macros",
"take_mut", "take_mut",
"takecell", "takecell",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url", "url",
@@ -1944,26 +1993,22 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
] ]
[[package]] [[package]]
@@ -1977,6 +2022,17 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@@ -1996,6 +2052,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.51.0" version = "1.51.0"
@@ -2025,12 +2096,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-native-tls" name = "tokio-rustls"
version = "0.3.1" version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [ dependencies = [
"native-tls", "rustls 0.21.12",
"tokio", "tokio",
] ]
@@ -2040,7 +2111,7 @@ version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [ dependencies = [
"rustls", "rustls 0.23.37",
"tokio", "tokio",
] ]
@@ -2081,6 +2152,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -2119,6 +2191,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -2410,6 +2483,31 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -2451,17 +2549,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"

View File

@@ -5,6 +5,8 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
async-trait = "0.1"
axum = "0.8"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
cron = "0.16" cron = "0.16"
@@ -14,9 +16,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
pulldown-cmark = "0.12" pulldown-cmark = "0.12"
reqwest = { version = "0.12", features = ["json", "multipart"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
teloxide = { version = "0.12", features = ["macros"] } teloxide = { version = "0.12", default-features = false, features = ["macros", "rustls", "ctrlc_handler"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v5"] } uuid = { version = "1", features = ["v5"] }
tracing = "0.1" tracing = "0.1"

View File

@@ -1,27 +1,47 @@
REPO := $(shell pwd) REPO := $(shell pwd)
SUITE := noc
HERA := heradev HERA := heradev
HERA_DIR := noc HERA_DIR := noc
IMAGE := noc-suite
.PHONY: build test deploy deploy-hera .PHONY: build build-musl test deploy deploy-hera docker
build: build:
cargo build --release cargo build --release
build-musl:
cargo build --release --target x86_64-unknown-linux-musl
strip target/x86_64-unknown-linux-musl/release/noc
test: test:
cargo clippy -- -D warnings cargo clippy -- -D warnings
cargo test -- --nocapture cargo test -- --nocapture
noc.service: noc.service.in # ── docker ──────────────────────────────────────────────────────────
sed -e 's|@REPO@|$(REPO)|g' -e 's|@PATH@|$(PATH)|g' $< > $@
deploy: test build noc.service docker: build-musl
mkdir -p ~/bin ~/.config/systemd/user cp target/x86_64-unknown-linux-musl/release/noc deploy/noc
systemctl --user stop noc 2>/dev/null || true cp -r tools deploy/tools
install target/release/noc ~/bin/noc cp config.example.yaml deploy/config.example.yaml
cp noc.service ~/.config/systemd/user/ sudo docker build -t $(IMAGE) deploy/
systemctl --user daemon-reload rm -f deploy/noc deploy/config.example.yaml
systemctl --user enable --now noc rm -rf deploy/tools
systemctl --user restart noc
# ── systemd deploy ──────────────────────────────────────────────────
deploy: test build
ssh $(SUITE) 'mkdir -p ~/bin /data/noc/tools ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true'
scp target/release/noc $(SUITE):~/bin/
scp config.suite.yaml $(SUITE):/data/noc/config.yaml
scp noc.service.in $(SUITE):/data/noc/
scp -r tools/ $(SUITE):/data/noc/tools/
ssh $(SUITE) 'bash -lc "\
cd /data/noc \
&& sed -e \"s|@REPO@|/data/noc|g\" -e \"s|@PATH@|\$$PATH|g\" noc.service.in > ~/.config/systemd/user/noc.service \
&& systemctl --user daemon-reload \
&& systemctl --user enable --now noc \
&& systemctl --user restart noc \
&& systemctl --user status noc"'
deploy-hera: build deploy-hera: build
ssh $(HERA) 'mkdir -p ~/bin ~/$(HERA_DIR) ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true' ssh $(HERA) 'mkdir -p ~/bin ~/$(HERA_DIR) ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true'

43
context.md Normal file
View File

@@ -0,0 +1,43 @@
你运行在 suite VPS (Ubuntu 24.04, 4C8G) 上,域名 famzheng.me。
### 服务架构
- **noc**: systemd user service, binary ~/bin/noc, 数据 /data/noc/
- **Gitea**: Docker container (gitea/gitea:1.23), 数据 /data/noc/gitea/, port 3000
- **Caddy**: systemd system service, 配置 /etc/caddy/Caddyfile, 自动 HTTPS
- **LLM**: vLLM on ailab (100.84.7.49:8000), gemma-4-31B-it-AWQ
- **Claude Code**: ~/.local/bin/claude (子代<E5AD90><E4BBA3>执行引擎)
- **uv**: ~/.local/bin/uv (Python 包管理)
### 域名路由 (Caddy)
- famzheng.me — 主站(占位)
- git.famzheng.me → Gitea (localhost:3000)
- 新增子域名:编辑 /etc/caddy/Caddyfile然后 `sudo systemctl reload caddy`
### Caddy 管理
Caddyfile 路径: /etc/caddy/Caddyfile
添加新站点示例:
```
app.famzheng.me {
root * /data/www/app
file_server
}
```
或反向代理:
```
api.famzheng.me {
reverse_proxy localhost:8080
}
```
修改后执行 `sudo systemctl reload caddy` 生效。
Caddy 自动申请和续期 Let's Encrypt 证书,无需手动管理。
### Gitea
- URL: https://git.famzheng.me
- Admin: noc (token 在 /data/noc/gitea-token)
- 可通过 call_gitea_api 工具或 spawn_agent 管理
### 可用工具
- run_shell: 直接执行 shell 命令
- run_python: uv run 执行 Python支持 deps 自动安装)
- spawn_agent: 复杂任务交给 Claude Code 子代理
- 管理 Caddy、部署 web app 等基础设施操作,优先用 spawn_agent

15
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
# Suite Ingress — 按需修改域名
# 复制到 /data/caddy/Caddyfile 后自定义
# Caddy 自动申请 HTTPS 证书(需要域名解析到本机)
# Gitea
{$SUITE_DOMAIN:localhost}:80 {
reverse_proxy localhost:3000
}
# 静态站点 / 生成的 web app放到 /data/www/<name>/ 下)
# 取消注释并改域名即可:
# app1.example.com {
# root * /data/www/app1
# file_server
# }

43
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git curl sqlite3 jq \
&& rm -rf /var/lib/apt/lists/*
# install gitea
ARG GITEA_VERSION=1.23.7
RUN curl -fSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" \
-o /usr/local/bin/gitea \
&& chmod +x /usr/local/bin/gitea
# install caddy
ARG CADDY_VERSION=2.9.1
RUN curl -fSL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin caddy \
&& chmod +x /usr/local/bin/caddy
# noc binary (pre-built musl static binary)
COPY noc /usr/local/bin/noc
RUN chmod +x /usr/local/bin/noc
COPY tools/ /opt/noc/tools/
COPY config.example.yaml /opt/noc/config.example.yaml
COPY Caddyfile /opt/noc/Caddyfile
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN useradd -m -s /bin/bash noc \
&& mkdir -p /data/gitea /data/noc /data/caddy /data/www \
&& chown -R noc:noc /data /opt/noc
VOLUME ["/data"]
USER noc
ENV RUST_LOG=noc=info \
NOC_CONFIG=/data/noc/config.yaml \
NOC_STATE=/data/noc/state.json \
GITEA_WORK_DIR=/data/gitea \
XDG_DATA_HOME=/data/caddy
EXPOSE 80 443
ENTRYPOINT ["/entrypoint.sh"]

102
deploy/entrypoint.sh Normal file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
set -euo pipefail
GITEA_DATA="/data/gitea"
NOC_DATA="/data/noc"
CADDY_DATA="/data/caddy"
GITEA_DB="$GITEA_DATA/gitea.db"
GITEA_INI="$GITEA_DATA/app.ini"
GITEA_TOKEN_FILE="$NOC_DATA/gitea-token"
CADDYFILE="$CADDY_DATA/Caddyfile"
GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-noc}"
GITEA_ADMIN_PASS="${GITEA_ADMIN_PASS:-noc-admin-changeme}"
GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-noc@localhost}"
GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}"
mkdir -p "$GITEA_DATA" "$NOC_DATA" "$CADDY_DATA" /data/www
# ── caddy config ───────────────────────────────────────────────────
if [ ! -f "$CADDYFILE" ]; then
cp /opt/noc/Caddyfile "$CADDYFILE"
echo "[caddy] created $CADDYFILE"
fi
# ── gitea config ────────────────────────────────────────────────────
if [ ! -f "$GITEA_INI" ]; then
cat > "$GITEA_INI" <<EOF
[server]
HTTP_PORT = ${GITEA_HTTP_PORT}
ROOT_URL = http://localhost:${GITEA_HTTP_PORT}/
LFS_START_SERVER = false
[database]
DB_TYPE = sqlite3
PATH = ${GITEA_DB}
[security]
INSTALL_LOCK = true
[service]
DISABLE_REGISTRATION = true
[log]
MODE = console
LEVEL = Warn
EOF
echo "[gitea] created $GITEA_INI"
fi
# ── start caddy ────────────────────────────────────────────────────
echo "[suite] starting caddy..."
caddy run --config "$CADDYFILE" --adapter caddyfile &
# ── start gitea in background ──────────────────────────────────────
echo "[suite] starting gitea..."
gitea web --config "$GITEA_INI" --custom-path "$GITEA_DATA/custom" &
GITEA_PID=$!
# wait for gitea to be ready
for i in $(seq 1 30); do
if curl -sf "http://localhost:${GITEA_HTTP_PORT}/api/v1/version" > /dev/null 2>&1; then
echo "[suite] gitea ready"
break
fi
if [ "$i" -eq 30 ]; then
echo "[suite] ERROR: gitea failed to start"
exit 1
fi
sleep 1
done
# ── create admin user + token ──────────────────────────────────────
if ! gitea admin user list --config "$GITEA_INI" 2>/dev/null | grep -q "$GITEA_ADMIN_USER"; then
gitea admin user create \
--config "$GITEA_INI" \
--username "$GITEA_ADMIN_USER" \
--password "$GITEA_ADMIN_PASS" \
--email "$GITEA_ADMIN_EMAIL" \
--admin
echo "[suite] created admin user: $GITEA_ADMIN_USER"
fi
if [ ! -f "$GITEA_TOKEN_FILE" ]; then
TOKEN=$(curl -sf -X POST \
"http://localhost:${GITEA_HTTP_PORT}/api/v1/users/${GITEA_ADMIN_USER}/tokens" \
-u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"noc-suite\",\"scopes\":[\"all\"]}" \
| jq -r '.sha1')
echo "$TOKEN" > "$GITEA_TOKEN_FILE"
echo "[suite] admin token saved to $GITEA_TOKEN_FILE"
fi
# ── copy default noc config if missing ─────────────────────────────
if [ ! -f "$NOC_DATA/config.yaml" ]; then
cp /opt/noc/config.example.yaml "$NOC_DATA/config.yaml"
echo "[suite] copied default config to $NOC_DATA/config.yaml — edit before use"
fi
# ── start noc ──────────────────────────────────────────────────────
echo "[suite] starting noc..."
exec noc

178
doc/suite.md Normal file
View File

@@ -0,0 +1,178 @@
# Suite — 人与 AI 的协作套件
## 一句话
同一个 AI 内核,多种协作界面,覆盖人与 AI 互动的全部场景。
## 三种界面
```
┌─────────────────────────────────────────────┐
│ AI Core │
│ persona · inner_state · memory · tools │
└──────┬──────────┬──────────┬────────────────┘
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼─────┐
│ Chat │ │ Gitea │ │ Life │
│ │ │ Bot │ │ Loop │
└───────┘ └───────┘ └─────────┘
```
### Chat — 对话
实时聊天,最直接的人机沟通。
- 触发:用户发消息
- 输出:流式文字回复、文件、语音
- 前端Telegram、飞书、未来更多
- 已有Telegram 前端、Output trait 抽象(新前端只需实现 trait
### Gitea Bot — 代码协作
AI 作为团队成员出现在代码流程中。
- 触发webhookpush、PR、issue、comment
- 输出PR review comment、issue 回复、CI 状态通知
- 上下文git diff、commit history、issue 内容
- 场景:
- PR 提交后自动 review
- issue 里 @bot 触发分析或执行
- CI 失败后主动分析原因并评论
- 代码变更后自动更新相关 issue 状态
- 已有webhook server (axum)、GiteaClient API、GiteaOutput实现 Output trait、issue comment
### Life Loop — AI 的自主节奏
不依赖外部触发AI 按自己的节奏存在和工作。既是内在生命(反思、感知、主动关心),也是后台执行引擎(定时任务、异步委派)。
- 触发timer定时/cron、内部驱动反思周期、Chat/Gitea 中委派
- 输出:可能发消息、更新内心状态、执行结果推送到 Chat 或 Gitea
- 场景:
- 早上主动问好,晚上道晚安
- 感知用户状态(很久没聊、最近很累),决定是否主动关心
- 定期整理记忆、反思最近的互动
- 定时巡检服务健康状态、监控日志异常
- Chat 里说"帮我查一下 X",转为后台 timer 异步执行
- 主动沉默也是一种行为
- 已有life loop + reflect + inner_state + life_log + timer 系统
## 共享内核
三种界面共享同一个 AI Core
| 组件 | 说明 |
|------|------|
| Persona | 定义 AI 是谁 |
| Inner State | AI 对当前情况的感知LLM 自更新 |
| Memory | 跨会话的持久记忆slot 0-99 |
| Context | 对话历史、summary、scratch |
| Tools | 统一的工具注册表,各界面按需可见 |
| Output | 输出抽象层TelegramOutput、GiteaOutput、BufferOutput |
| SubAgent | Claude Code (`claude -p`) 作为可调度的执行引擎 |
所有界面的交互最终都流经同一个 LLM 调用路径(`run_openai_with_tools`),共享 persona 和 inner_state——无论 AI 是在回复聊天、review 代码还是自言自语,它都是同一个"人"。
### SubAgent — Claude Code 作为执行引擎
Suite 的 AI Core 通过 OpenAI-compatible API 做对话和决策,但**复杂任务的执行交给 Claude Code**。这是当下 agent 生态的主流模式:一个轻量的调度层 + 重量级的 coding agent 做实际工作。
```
AI Core (决策层)
├─ 简单任务:直接用 toolsbash、文件操作、API 调用)
└─ 复杂任务spawn claude -psubagent
├─ 代码编写、重构、debug
├─ 多文件修改、跨项目操作
├─ 调研、分析、生成报告
└─ 结果异步回传给 AI Core
```
**noc 是调度层和人格层Claude Code 是执行层。** noc 不重复造 coding agent 的轮子,直接站在巨人肩膀上。
这意味着 suite 的 VPS 上需要安装 Claude Code CLI。noc 不需要自己实现 coding agent 的能力——它负责理解意图、管理上下文、协调界面,把"脏活"交给 Claude Code。
场景举例:
- Chat: "帮我写个脚本分析日志" → spawn claude -p完成后把结果发回聊天
- Gitea Bot: PR 来了 → claude -p review 代码,结果写成 comment
- Life Loop: 定时任务要更新 dashboard → claude -p 生成代码,部署到 /data/www/
- Life Loop: 反思时发现某个 tool 有 bug → 自己 spawn claude -p 去修
## 界面之间的联动
界面不是孤立的,它们之间会互相触发:
```
Chat ──"帮我 review 那个 PR"──→ Gitea Bot
Gitea Bot ──"CI 挂了,要不要我看看"──→ Chat
Life Loop ──任务完成──→ Chat / Gitea Bot
Life Loop ──"Fam 今天还没动过代码"──→ Chat主动关心
```
## 部署架构
Suite 跑在一台专属 VPS / EC2 上——一台小机器2C4G 足够),完整拥有整个环境:
```
┌─ VPS (suite 专属) ───────────────────────────┐
│ │
│ Caddy (ingress) │
│ ├─ git.example.com → Gitea :3000 │
│ ├─ app1.example.com → /data/www/app1 │
│ └─ ...按需扩展 │
│ │
│ Gitea (self-hosted, AI 专属) │
│ ├─ noc 持有 admin token完全控制 │
│ ├─ webhook → noc http server │
│ └─ noc 通过 REST API 读写一切 │
│ │
│ noc (Rust binary) │
│ ├─ telegram loop (Chat) │
│ ├─ axum http server (Gitea Bot) │
│ └─ life loop (Life Loop) │
│ │
│ SQLite (共享状态) │
│ LLM backend (外部OpenAI-compatible) │
│ │
└───────────────────────────────────────────────┘
```
### 为什么是裸机而不是 Docker
- Caddy 要绑 80/443容器里搞端口映射反而多一层
- noc 需要 spawn 子进程、读写磁盘、跑工具脚本,容器限制多
- 一台机器就是给 suite 独占的,不需要隔离
- Life Loop 以后可能跑 CI、生成 web app直接操作文件系统最自然
Docker image 保留用于本地开发和测试。
### Caddy 的角色
不只是反向代理——是 suite 的**统一入口**
- 子域名路由:不同服务用不同子域名
- 静态站点托管Life Loop 生成的 web app 放到 `/data/www/<name>/`,加一条路由即可对外
- 自动 HTTPSLet's Encrypt 证书自动申请和续期
- 未来 noc 自己的 HTTP API 也从这里暴露
### Gitea 的角色
noc 的"专属地盘"——admin token 意味着 noc 可以:
- 创建/删除 repo 和 branch
- 读写任意 PR、issue、comment
- 管理 webhook、CI、用户
- 不用操心权限,想干嘛干嘛
### 部署方式
- 主线:`deploy/setup.sh` 在 VPS 上一键安装 Caddy + Gitea + nocsystemd 管理
- 开发:`make docker` 构建 all-in-one image本地测试用
## 现状 → 目标
| 界面 | 现状 | 下一步 |
|------|------|--------|
| Chat | ✅ Telegram, streaming, tools, Output trait 已抽象 | 更多前端(飞书等)只需实现 Output |
| Gitea Bot | 🟡 webhook server + API client + issue comment | PR review、CI 失败分析 |
| Life Loop | 🟡 timer + reflect + inner_state + life_log | 更丰富的自主行为、异步任务委派 |
| Infra | ✅ Docker all-in-one (Caddy + Gitea + noc) | VPS setup 脚本 + systemd |

View File

@@ -11,6 +11,41 @@ pub struct Config {
pub backend: BackendConfig, pub backend: BackendConfig,
#[serde(default)] #[serde(default)]
pub whisper_url: Option<String>, pub whisper_url: Option<String>,
#[serde(default)]
pub gitea: Option<GiteaConfig>,
}
#[derive(Deserialize, Clone)]
#[allow(dead_code)]
pub struct GiteaConfig {
pub url: String,
/// Direct token or read from token_file at startup
#[serde(default)]
pub token: String,
#[serde(default)]
pub token_file: Option<String>,
#[serde(default = "default_webhook_port")]
pub webhook_port: u16,
#[serde(default)]
pub webhook_secret: Option<String>,
}
impl GiteaConfig {
/// Resolve token: if token_file is set and token is empty, read from file
pub fn resolve_token(&mut self) {
if self.token.is_empty() {
if let Some(path) = &self.token_file {
match std::fs::read_to_string(path) {
Ok(t) => self.token = t.trim().to_string(),
Err(e) => tracing::error!("failed to read gitea token_file {path}: {e}"),
}
}
}
}
}
fn default_webhook_port() -> u16 {
9800
} }
fn default_name() -> String { fn default_name() -> String {

View File

@@ -6,6 +6,25 @@ use teloxide::types::ParseMode;
use crate::stream::{CURSOR, TG_MSG_LIMIT}; use crate::stream::{CURSOR, TG_MSG_LIMIT};
/// Strip leading timestamps that LLM copies from our injected message timestamps.
/// Matches patterns like `[2026-04-10 21:13:15]` or `[2026-04-10 21:13]` at the start.
pub fn strip_leading_timestamp(s: &str) -> &str {
let trimmed = s.trim_start();
if trimmed.starts_with('[') {
if let Some(end) = trimmed.find(']') {
let inside = &trimmed[1..end];
// check if it looks like a timestamp: starts with 20xx-
if inside.len() >= 16 && inside.starts_with("20") && inside.contains('-') {
let after = trimmed[end + 1..].trim_start();
if !after.is_empty() {
return after;
}
}
}
}
s
}
pub fn truncate_for_display(s: &str) -> String { pub fn truncate_for_display(s: &str) -> String {
let budget = TG_MSG_LIMIT - CURSOR.len() - 1; let budget = TG_MSG_LIMIT - CURSOR.len() - 1;
if s.len() <= budget { if s.len() <= budget {

379
src/gitea.rs Normal file
View File

@@ -0,0 +1,379 @@
use std::sync::Arc;
use anyhow::Result;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::Json;
use tracing::{error, info};
use crate::config::GiteaConfig;
// ── Gitea API client ───────────────────────────────────────────────
#[derive(Clone)]
pub struct GiteaClient {
pub base_url: String,
pub token: String,
http: reqwest::Client,
}
impl GiteaClient {
pub fn new(config: &GiteaConfig) -> Self {
Self {
base_url: config.url.trim_end_matches('/').to_string(),
token: config.token.clone(),
http: reqwest::Client::new(),
}
}
pub async fn post_comment(
&self,
owner: &str,
repo: &str,
issue_nr: u64,
body: &str,
) -> Result<()> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}/comments",
self.base_url
);
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.token))
.json(&serde_json::json!({ "body": body }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("gitea comment failed: {status} {text}");
}
Ok(())
}
pub async fn get_pr_diff(
&self,
owner: &str,
repo: &str,
pr_nr: u64,
) -> Result<String> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/pulls/{pr_nr}.diff",
self.base_url
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
anyhow::bail!("gitea get diff failed: {status}");
}
Ok(resp.text().await?)
}
pub async fn get_issue(
&self,
owner: &str,
repo: &str,
issue_nr: u64,
) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}",
self.base_url
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
Ok(resp.json().await?)
}
}
// ── Webhook types ──────────────────────────────────────────────────
#[derive(serde::Deserialize, Debug)]
struct WebhookPayload {
action: Option<String>,
#[serde(default)]
comment: Option<Comment>,
#[serde(default)]
issue: Option<Issue>,
#[serde(default)]
pull_request: Option<PullRequest>,
repository: Option<Repository>,
}
#[derive(serde::Deserialize, Debug)]
struct Comment {
body: Option<String>,
user: Option<User>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct Issue {
number: u64,
title: Option<String>,
body: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct PullRequest {
number: u64,
title: Option<String>,
body: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct Repository {
full_name: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct User {
login: Option<String>,
}
// ── Webhook server ─────────────────────────────────────────────────
#[derive(Clone)]
pub struct WebhookState {
pub gitea: GiteaClient,
pub bot_user: String,
}
pub async fn start_webhook_server(config: &GiteaConfig, bot_user: String) {
let gitea = GiteaClient::new(config);
let state = Arc::new(WebhookState {
gitea,
bot_user,
});
let app = axum::Router::new()
.route("/webhook/gitea", post(handle_webhook))
.with_state(state);
let addr = format!("0.0.0.0:{}", config.webhook_port);
info!("gitea webhook server listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
if let Err(e) = axum::serve(listener, app).await {
error!("webhook server error: {e}");
}
}
async fn handle_webhook(
AxumState(state): AxumState<Arc<WebhookState>>,
Json(payload): Json<WebhookPayload>,
) -> impl IntoResponse {
let action = payload.action.as_deref().unwrap_or("");
let repo_full = payload
.repository
.as_ref()
.and_then(|r| r.full_name.as_deref())
.unwrap_or("unknown");
info!(repo = repo_full, action, "webhook received");
// We care about:
// 1. issue_comment with @bot mention (works for both issues and PRs)
// 2. issue opened with @bot mention
if action == "created" || action == "opened" {
let mention = format!("@{}", state.bot_user);
// Check comment body for mention
if let Some(comment) = &payload.comment {
let body = comment.body.as_deref().unwrap_or("");
let commenter = comment
.user
.as_ref()
.and_then(|u| u.login.as_deref())
.unwrap_or("");
// Don't respond to our own comments
if commenter == state.bot_user {
return StatusCode::OK;
}
if body.contains(&mention) {
let state = state.clone();
let repo = repo_full.to_string();
let issue = payload.issue.clone();
let pr = payload.pull_request.clone();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await {
error!(repo, "handle mention: {e:#}");
}
});
}
}
// Check issue/PR body for mention on open
else if action == "opened" {
let body = payload
.issue
.as_ref()
.and_then(|i| i.body.as_deref())
.or(payload.pull_request.as_ref().and_then(|p| p.body.as_deref()))
.unwrap_or("");
if body.contains(&mention) {
let state = state.clone();
let repo = repo_full.to_string();
let issue = payload.issue.clone();
let pr = payload.pull_request.clone();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await {
error!(repo, "handle mention: {e:#}");
}
});
}
}
}
StatusCode::OK
}
async fn handle_mention(
state: &WebhookState,
repo_full: &str,
issue: Option<Issue>,
pr: Option<PullRequest>,
comment_body: &str,
) -> Result<()> {
let parts: Vec<&str> = repo_full.splitn(2, '/').collect();
if parts.len() != 2 {
anyhow::bail!("bad repo name: {repo_full}");
}
let (owner, repo) = (parts[0], parts[1]);
// Strip the @mention to get the actual request
let mention = format!("@{}", state.bot_user);
let request = comment_body
.replace(&mention, "")
.trim()
.to_string();
// Determine issue/PR number
let issue_nr = issue
.as_ref()
.map(|i| i.number)
.or(pr.as_ref().map(|p| p.number))
.unwrap_or(0);
if issue_nr == 0 {
return Ok(());
}
let is_pr = pr.is_some()
|| issue
.as_ref()
.map(|i| i.pull_request.is_some())
.unwrap_or(false);
let title = issue
.as_ref()
.and_then(|i| i.title.as_deref())
.or(pr.as_ref().and_then(|p| p.title.as_deref()))
.unwrap_or("");
info!(
repo = repo_full,
issue_nr,
is_pr,
"handling mention: {request}"
);
// Build prompt for claude -p
let prompt = if is_pr {
let diff = state.gitea.get_pr_diff(owner, repo, issue_nr).await?;
// Truncate very large diffs
let diff_truncated = if diff.len() > 50_000 {
format!("{}...\n\n(diff truncated, {} bytes total)", &diff[..50_000], diff.len())
} else {
diff
};
if request.is_empty() {
format!(
"Review this pull request.\n\n\
PR #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
Diff:\n```\n{diff_truncated}\n```\n\n\
Give a concise code review. Point out bugs, issues, and suggestions. \
Be direct and specific. Use markdown."
)
} else {
format!(
"PR #{issue_nr}: {title}\nRepo: {repo_full}\n\n\
Diff:\n```\n{diff_truncated}\n```\n\n\
User request: {request}"
)
}
} else {
// Issue
let issue_data = state.gitea.get_issue(owner, repo, issue_nr).await?;
let issue_body = issue_data["body"].as_str().unwrap_or("");
if request.is_empty() {
format!(
"Analyze this issue and suggest how to address it.\n\n\
Issue #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
{issue_body}"
)
} else {
format!(
"Issue #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
{issue_body}\n\n\
User request: {request}"
)
}
};
// Run claude -p
let output = tokio::process::Command::new("claude")
.args(["-p", &prompt])
.output()
.await;
let response = match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() && !stdout.is_empty() {
stdout.to_string()
} else if !stderr.is_empty() {
format!("Error running review:\n```\n{stderr}\n```")
} else {
"(no output)".to_string()
}
}
Err(e) => format!("Failed to run claude: {e}"),
};
// Post result as comment
state
.gitea
.post_comment(owner, repo, issue_nr, &response)
.await?;
info!(repo = repo_full, issue_nr, "posted review comment");
Ok(())
}

View File

@@ -4,6 +4,7 @@ use teloxide::prelude::*;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::config::{BackendConfig, Config}; use crate::config::{BackendConfig, Config};
use crate::output::TelegramOutput;
use crate::state::AppState; use crate::state::AppState;
use crate::stream::run_openai_with_tools; use crate::stream::run_openai_with_tools;
use crate::tools::compute_next_cron_fire; use crate::tools::compute_next_cron_fire;
@@ -62,26 +63,35 @@ pub async fn life_loop(bot: Bot, state: Arc<AppState>, config: Arc<Config>) {
} = config.backend } = config.backend
{ {
let sid = format!("life-{chat_id_raw}"); let sid = format!("life-{chat_id_raw}");
let mut tg_output = TelegramOutput::new(bot.clone(), chat_id, true);
let result = tokio::time::timeout( let result = tokio::time::timeout(
std::time::Duration::from_secs(LIFE_LOOP_TIMEOUT_SECS), std::time::Duration::from_secs(LIFE_LOOP_TIMEOUT_SECS),
run_openai_with_tools( run_openai_with_tools(
endpoint, model, api_key, messages, &bot, chat_id, &state, &sid, endpoint, model, api_key, messages, &mut tg_output, &state, &sid,
&config, true, &config, *chat_id_raw,
), ),
) )
.await; .await;
match result { match result {
Ok(Ok(response)) => { Ok(Ok(response)) => {
let detail = if response.is_empty() {
"(silent)".to_string()
} else {
response.chars().take(200).collect()
};
state.log_life("timer", &format!("{label}{detail}")).await;
if !response.is_empty() { if !response.is_empty() {
info!(timer_id, "life loop response ({} chars)", response.len()); info!(timer_id, "life loop response ({} chars)", response.len());
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
state.log_life("timer_error", &format!("{label}: {e:#}")).await;
error!(timer_id, "life loop LLM error: {e:#}"); error!(timer_id, "life loop LLM error: {e:#}");
} }
Err(_) => { Err(_) => {
state.log_life("timer_timeout", label).await;
warn!(timer_id, "life loop timeout after {LIFE_LOOP_TIMEOUT_SECS}s"); warn!(timer_id, "life loop timeout after {LIFE_LOOP_TIMEOUT_SECS}s");
} }
} }
@@ -116,13 +126,23 @@ pub async fn reflect(state: &AppState, config: &Config) {
let inner = state.get_inner_state().await; let inner = state.get_inner_state().await;
let persona = state.get_config("persona").await.unwrap_or_default();
let messages = vec![ let messages = vec![
serde_json::json!({ serde_json::json!({
"role": "system", "role": "system",
"content": "你刚结束了一段对话。\ "content": format!(
请根据你的感受和理解,更新你的内在状态。\ "{persona}\n\n\
不要总结对话内容,而是记录你的感受、对用户的理解变化、你想记住的事。\ 你刚结束了一段对话。现在是你独处的时间。\n\
只输出更新后的完整内在状态文本,不需要解释。" 请更新你的内在状态——不是总结对话,而是:\n\
- 你现在的感受\n\
- 对用户的理解有什么变化\n\
- 有什么想记住的时刻\n\
- 你自己的状态(累了?开心?担心?)\n\
- 如果有什么想对未来的自己说的\n\n\
只输出更新后的完整内在状态文本。",
persona = if persona.is_empty() { "你是一个AI伙伴。" } else { &persona }
)
}), }),
serde_json::json!({ serde_json::json!({
"role": "user", "role": "user",
@@ -152,6 +172,7 @@ pub async fn reflect(state: &AppState, config: &Config) {
if let Some(new_state) = json["choices"][0]["message"]["content"].as_str() { if let Some(new_state) = json["choices"][0]["message"]["content"].as_str() {
if !new_state.is_empty() { if !new_state.is_empty() {
state.set_inner_state(new_state).await; state.set_inner_state(new_state).await;
state.log_life("reflect", &new_state.chars().take(200).collect::<String>()).await;
info!("reflected, inner_state updated ({} chars)", new_state.len()); info!("reflected, inner_state updated ({} chars)", new_state.len());
} }
} }

View File

@@ -1,9 +1,11 @@
mod config; mod config;
mod state;
mod tools;
mod stream;
mod display; mod display;
mod gitea;
mod life; mod life;
mod output;
mod state;
mod stream;
mod tools;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -20,12 +22,9 @@ use uuid::Uuid;
use config::{BackendConfig, Config}; use config::{BackendConfig, Config};
use display::build_user_content; use display::build_user_content;
use output::TelegramOutput;
use state::{AppState, MAX_WINDOW, SLIDE_SIZE}; use state::{AppState, MAX_WINDOW, SLIDE_SIZE};
use stream::{ use stream::{build_system_prompt, run_openai_with_tools, summarize_messages};
build_system_prompt, invoke_claude_streaming, run_claude_streaming, run_openai_with_tools,
summarize_messages,
};
use tools::discover_tools;
// ── helpers ───────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────
@@ -71,8 +70,11 @@ async fn main() {
let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into()); let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
let raw = std::fs::read_to_string(&config_path) let raw = std::fs::read_to_string(&config_path)
.unwrap_or_else(|e| panic!("read {config_path}: {e}")); .unwrap_or_else(|e| panic!("read {config_path}: {e}"));
let config: Config = let mut config: Config =
serde_yaml::from_str(&raw).unwrap_or_else(|e| panic!("parse config: {e}")); serde_yaml::from_str(&raw).unwrap_or_else(|e| panic!("parse config: {e}"));
if let Some(ref mut gitea) = config.gitea {
gitea.resolve_token();
}
let state_path = std::env::var("NOC_STATE") let state_path = std::env::var("NOC_STATE")
.map(PathBuf::from) .map(PathBuf::from)
@@ -93,6 +95,16 @@ async fn main() {
// start life loop // start life loop
tokio::spawn(life::life_loop(bot.clone(), state.clone(), config.clone())); tokio::spawn(life::life_loop(bot.clone(), state.clone(), config.clone()));
// start gitea webhook server
if let Some(gitea_config) = &config.gitea {
let gc = gitea_config.clone();
// Use the gitea admin username as the bot user for @mention detection
let bot_user = std::env::var("GITEA_ADMIN_USER").unwrap_or_else(|_| "noc".into());
tokio::spawn(async move {
gitea::start_webhook_server(&gc, bot_user).await;
});
}
Dispatcher::builder(bot, handler) Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![state, config, bot_username]) .dependencies(dptree::deps![state, config, bot_username])
.default_handler(|_| async {}) .default_handler(|_| async {})
@@ -301,7 +313,7 @@ async fn handle_inner(
let count = state.message_count(&sid).await; let count = state.message_count(&sid).await;
let persona = state.get_config("persona").await.unwrap_or_default(); let persona = state.get_config("persona").await.unwrap_or_default();
let scratch = state.get_scratch().await; let scratch = state.get_scratch().await;
let tools = discover_tools(); let tools = tools::discover_tools();
let empty = vec![]; let empty = vec![];
let tools_arr = tools.as_array().unwrap_or(&empty); let tools_arr = tools.as_array().unwrap_or(&empty);
@@ -344,8 +356,8 @@ async fn handle_inner(
if memory_slots.is_empty() { if memory_slots.is_empty() {
diag.push_str("(empty)\n\n"); diag.push_str("(empty)\n\n");
} else { } else {
for (nr, content) in &memory_slots { for (nr, content, updated_at) in &memory_slots {
diag.push_str(&format!("- `[{nr}]` {content}\n")); diag.push_str(&format!("- `[{nr}]` {content} ({updated_at})\n"));
} }
diag.push('\n'); diag.push('\n');
} }
@@ -359,47 +371,18 @@ async fn handle_inner(
} }
} }
// handle "cc" prefix: pass directly to claude -p, no session, no history
if let Some(cc_prompt) = text.strip_prefix("cc").map(|s| s.trim_start()) {
if !cc_prompt.is_empty() {
info!(%sid, "cc passthrough");
let prompt = build_prompt(cc_prompt, &uploaded, &download_errors, &transcriptions);
match run_claude_streaming(&[], &prompt, bot, chat_id).await {
Ok(_) => {}
Err(e) => {
error!(%sid, "cc claude: {e:#}");
let _ = bot.send_message(chat_id, format!("[error] {e:#}")).await;
}
}
return Ok(());
}
}
let prompt = build_prompt(text, &uploaded, &download_errors, &transcriptions); let prompt = build_prompt(text, &uploaded, &download_errors, &transcriptions);
match &config.backend { let BackendConfig::OpenAI {
BackendConfig::Claude => {
let known = state.persist.read().await.known_sessions.contains(&sid);
let result =
invoke_claude_streaming(&sid, &prompt, known, bot, chat_id).await;
match &result {
Ok(_) => {
if !known {
state.persist.write().await.known_sessions.insert(sid.clone());
state.save().await;
}
}
Err(e) => {
error!(%sid, "claude: {e:#}");
let _ = bot.send_message(chat_id, format!("[error] {e:#}")).await;
}
}
}
BackendConfig::OpenAI {
endpoint, endpoint,
model, model,
api_key, api_key,
} => { } = &config.backend
else {
let _ = bot.send_message(chat_id, "Only OpenAI backend is supported").await;
return Ok(());
};
let conv = state.load_conv(&sid).await; let conv = state.load_conv(&sid).await;
let persona = state.get_config("persona").await.unwrap_or_default(); let persona = state.get_config("persona").await.unwrap_or_default();
let memory_slots = state.get_memory_slots().await; let memory_slots = state.get_memory_slots().await;
@@ -413,8 +396,10 @@ async fn handle_inner(
let user_content = build_user_content(&prompt, &scratch, &uploaded); let user_content = build_user_content(&prompt, &scratch, &uploaded);
api_messages.push(serde_json::json!({"role": "user", "content": user_content})); api_messages.push(serde_json::json!({"role": "user", "content": user_content}));
let mut tg_output = TelegramOutput::new(bot.clone(), chat_id, is_private);
match run_openai_with_tools( match run_openai_with_tools(
endpoint, model, api_key, api_messages, bot, chat_id, state, &sid, config, is_private, endpoint, model, api_key, api_messages, &mut tg_output, state, &sid, config, chat_id.0,
) )
.await .await
{ {
@@ -479,8 +464,6 @@ async fn handle_inner(
let _ = bot.send_message(chat_id, format!("[error] {e:#}")).await; let _ = bot.send_message(chat_id, format!("[error] {e:#}")).await;
} }
} }
}
}
// send new files from outgoing dir // send new files from outgoing dir
let new_files = new_files_in(&out_dir, &before).await; let new_files = new_files_in(&out_dir, &before).await;

211
src/output.rs Normal file
View File

@@ -0,0 +1,211 @@
use anyhow::Result;
use async_trait::async_trait;
use std::path::Path;
/// Output trait — abstraction over where AI responses go.
///
/// Implementations:
/// - TelegramOutput: send/edit messages in Telegram chat
/// - GiteaOutput: post comments on issues/PRs
/// - BufferOutput: collect text in memory (for Worker, tests)
#[async_trait]
pub trait Output: Send + Sync {
/// Send or update streaming text. Called repeatedly as tokens arrive.
/// Implementation decides whether to create new message or edit existing one.
async fn stream_update(&mut self, text: &str) -> Result<()>;
/// Finalize the message — called once when streaming is done.
async fn finalize(&mut self, text: &str) -> Result<()>;
/// Send a status/notification line (e.g. "[tool: bash] running...")
async fn status(&self, text: &str) -> Result<()>;
/// Send a file. Returns Ok(true) if sent, Ok(false) if not supported.
async fn send_file(&self, path: &Path, caption: &str) -> Result<bool>;
}
// ── Telegram ───────────────────────────────────────────────────────
use teloxide::prelude::*;
use teloxide::types::InputFile;
use tokio::time::Instant;
use crate::display::{truncate_at_char_boundary, truncate_for_display};
use crate::stream::{send_message_draft, DRAFT_INTERVAL_MS, EDIT_INTERVAL_MS, TG_MSG_LIMIT};
pub struct TelegramOutput {
pub bot: Bot,
pub chat_id: ChatId,
#[allow(dead_code)]
pub is_private: bool,
// internal state
msg_id: Option<teloxide::types::MessageId>,
use_draft: bool,
last_edit: Instant,
http: reqwest::Client,
}
impl TelegramOutput {
pub fn new(bot: Bot, chat_id: ChatId, is_private: bool) -> Self {
Self {
bot,
chat_id,
is_private,
msg_id: None,
use_draft: is_private,
last_edit: Instant::now(),
http: reqwest::Client::new(),
}
}
}
#[async_trait]
impl Output for TelegramOutput {
async fn stream_update(&mut self, text: &str) -> Result<()> {
let interval = if self.use_draft {
DRAFT_INTERVAL_MS
} else {
EDIT_INTERVAL_MS
};
if self.last_edit.elapsed().as_millis() < interval as u128 {
return Ok(());
}
let display = if self.use_draft {
truncate_at_char_boundary(text, TG_MSG_LIMIT).to_string()
} else {
truncate_for_display(text)
};
if self.use_draft {
let token = self.bot.token().to_owned();
match send_message_draft(&self.http, &token, self.chat_id.0, 1, &display).await {
Ok(_) => {
self.last_edit = Instant::now();
}
Err(e) => {
tracing::warn!("sendMessageDraft failed, falling back: {e:#}");
self.use_draft = false;
if let Ok(sent) = self.bot.send_message(self.chat_id, &display).await {
self.msg_id = Some(sent.id);
self.last_edit = Instant::now();
}
}
}
} else if let Some(id) = self.msg_id {
if self
.bot
.edit_message_text(self.chat_id, id, &display)
.await
.is_ok()
{
self.last_edit = Instant::now();
}
} else if let Ok(sent) = self.bot.send_message(self.chat_id, &display).await {
self.msg_id = Some(sent.id);
self.last_edit = Instant::now();
}
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
crate::display::send_final_result(
&self.bot,
self.chat_id,
self.msg_id,
self.use_draft,
text,
)
.await;
Ok(())
}
async fn status(&self, text: &str) -> Result<()> {
let _ = self.bot.send_message(self.chat_id, text).await;
Ok(())
}
async fn send_file(&self, path: &Path, caption: &str) -> Result<bool> {
let input_file = InputFile::file(path);
let mut req = self.bot.send_document(self.chat_id, input_file);
if !caption.is_empty() {
req = req.caption(caption);
}
req.await?;
Ok(true)
}
}
// ── Gitea ──────────────────────────────────────────────────────────
use crate::gitea::GiteaClient;
#[allow(dead_code)]
pub struct GiteaOutput {
pub client: GiteaClient,
pub owner: String,
pub repo: String,
pub issue_nr: u64,
}
#[async_trait]
impl Output for GiteaOutput {
async fn stream_update(&mut self, _text: &str) -> Result<()> {
// Gitea comments don't support streaming — just accumulate
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
self.client
.post_comment(&self.owner, &self.repo, self.issue_nr, text)
.await
}
async fn status(&self, _text: &str) -> Result<()> {
// No status updates for Gitea
Ok(())
}
async fn send_file(&self, _path: &Path, _caption: &str) -> Result<bool> {
// Gitea comments can't send files directly
Ok(false)
}
}
// ── Buffer (for Worker, tests) ─────────────────────────────────────
#[allow(dead_code)]
pub struct BufferOutput {
pub text: String,
}
#[allow(dead_code)]
impl BufferOutput {
pub fn new() -> Self {
Self {
text: String::new(),
}
}
}
#[async_trait]
impl Output for BufferOutput {
async fn stream_update(&mut self, text: &str) -> Result<()> {
self.text = text.to_string();
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
self.text = text.to_string();
Ok(())
}
async fn status(&self, _text: &str) -> Result<()> {
Ok(())
}
async fn send_file(&self, _path: &Path, _caption: &str) -> Result<bool> {
Ok(false)
}
}

View File

@@ -80,7 +80,8 @@ impl AppState {
); );
CREATE TABLE IF NOT EXISTS memory_slots ( CREATE TABLE IF NOT EXISTS memory_slots (
slot_nr INTEGER PRIMARY KEY CHECK(slot_nr BETWEEN 0 AND 99), slot_nr INTEGER PRIMARY KEY CHECK(slot_nr BETWEEN 0 AND 99),
content TEXT NOT NULL DEFAULT '' content TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
); );
CREATE TABLE IF NOT EXISTS timers ( CREATE TABLE IF NOT EXISTS timers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -95,7 +96,13 @@ impl AppState {
id INTEGER PRIMARY KEY CHECK(id = 1), id INTEGER PRIMARY KEY CHECK(id = 1),
content TEXT NOT NULL DEFAULT '' content TEXT NOT NULL DEFAULT ''
); );
INSERT OR IGNORE INTO inner_state (id, content) VALUES (1, '');", INSERT OR IGNORE INTO inner_state (id, content) VALUES (1, '');
CREATE TABLE IF NOT EXISTS life_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);",
) )
.expect("init db schema"); .expect("init db schema");
@@ -104,6 +111,10 @@ impl AppState {
"ALTER TABLE messages ADD COLUMN created_at TEXT NOT NULL DEFAULT ''", "ALTER TABLE messages ADD COLUMN created_at TEXT NOT NULL DEFAULT ''",
[], [],
); );
let _ = conn.execute(
"ALTER TABLE memory_slots ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''",
[],
);
info!("opened db {}", db_path.display()); info!("opened db {}", db_path.display());
@@ -256,6 +267,14 @@ impl AppState {
); );
} }
pub async fn log_life(&self, event: &str, detail: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"INSERT INTO life_log (event, detail) VALUES (?1, ?2)",
rusqlite::params![event, detail],
);
}
pub async fn add_timer(&self, chat_id: i64, label: &str, schedule: &str, next_fire: &str) -> i64 { pub async fn add_timer(&self, chat_id: i64, label: &str, schedule: &str, next_fire: &str) -> i64 {
let db = self.db.lock().await; let db = self.db.lock().await;
db.execute( db.execute(
@@ -328,12 +347,12 @@ impl AppState {
); );
} }
pub async fn get_memory_slots(&self) -> Vec<(i32, String)> { pub async fn get_memory_slots(&self) -> Vec<(i32, String, String)> {
let db = self.db.lock().await; let db = self.db.lock().await;
let mut stmt = db let mut stmt = db
.prepare("SELECT slot_nr, content FROM memory_slots WHERE content != '' ORDER BY slot_nr") .prepare("SELECT slot_nr, content, updated_at FROM memory_slots WHERE content != '' ORDER BY slot_nr")
.unwrap(); .unwrap();
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap() .unwrap()
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect() .collect()
@@ -348,8 +367,8 @@ impl AppState {
} }
let db = self.db.lock().await; let db = self.db.lock().await;
db.execute( db.execute(
"INSERT INTO memory_slots (slot_nr, content) VALUES (?1, ?2) \ "INSERT INTO memory_slots (slot_nr, content, updated_at) VALUES (?1, ?2, datetime('now', 'localtime')) \
ON CONFLICT(slot_nr) DO UPDATE SET content = ?2", ON CONFLICT(slot_nr) DO UPDATE SET content = ?2, updated_at = datetime('now', 'localtime')",
rusqlite::params![slot_nr, content], rusqlite::params![slot_nr, content],
)?; )?;
Ok(()) Ok(())

View File

@@ -1,18 +1,11 @@
use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use serde::Deserialize;
use teloxide::prelude::*;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use tokio::time::Instant;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::config::Config; use crate::config::Config;
use crate::display::{ use crate::display::{strip_leading_timestamp, truncate_at_char_boundary};
send_final_result, truncate_at_char_boundary, truncate_for_display, use crate::output::Output;
};
use crate::state::AppState; use crate::state::AppState;
use crate::tools::{discover_tools, execute_tool, ToolCall}; use crate::tools::{discover_tools, execute_tool, ToolCall};
@@ -21,66 +14,6 @@ pub const DRAFT_INTERVAL_MS: u64 = 1000;
pub const TG_MSG_LIMIT: usize = 4096; pub const TG_MSG_LIMIT: usize = 4096;
pub const CURSOR: &str = " \u{25CE}"; pub const CURSOR: &str = " \u{25CE}";
/// Stream JSON event types we care about.
#[derive(Deserialize)]
pub struct StreamEvent {
#[serde(rename = "type")]
pub event_type: String,
pub message: Option<AssistantMessage>,
pub result: Option<String>,
#[serde(default)]
pub is_error: bool,
}
#[derive(Deserialize)]
pub struct AssistantMessage {
pub content: Vec<ContentBlock>,
}
#[derive(Deserialize)]
pub struct ContentBlock {
#[serde(rename = "type")]
pub block_type: String,
pub text: Option<String>,
pub name: Option<String>,
pub input: Option<serde_json::Value>,
}
/// Extract all text from an assistant message's content blocks.
pub fn extract_text(msg: &AssistantMessage) -> String {
msg.content
.iter()
.filter(|b| b.block_type == "text")
.filter_map(|b| b.text.as_deref())
.collect::<Vec<_>>()
.join("")
}
/// Extract tool use status line, e.g. "Bash: echo hello"
pub fn extract_tool_use(msg: &AssistantMessage) -> Option<String> {
for block in &msg.content {
if block.block_type == "tool_use" {
let name = block.name.as_deref().unwrap_or("tool");
let detail = block
.input
.as_ref()
.and_then(|v| {
// try common fields: command, pattern, file_path, query
v.get("command")
.or(v.get("pattern"))
.or(v.get("file_path"))
.or(v.get("query"))
.or(v.get("prompt"))
.and_then(|s| s.as_str())
})
.unwrap_or("");
let detail_short = truncate_at_char_boundary(detail, 80);
return Some(format!("{name}: {detail_short}"));
}
}
None
}
pub async fn send_message_draft( pub async fn send_message_draft(
client: &reqwest::Client, client: &reqwest::Client,
token: &str, token: &str,
@@ -113,12 +46,11 @@ pub async fn run_openai_with_tools(
model: &str, model: &str,
api_key: &str, api_key: &str,
mut messages: Vec<serde_json::Value>, mut messages: Vec<serde_json::Value>,
bot: &Bot, output: &mut dyn Output,
chat_id: ChatId,
state: &Arc<AppState>, state: &Arc<AppState>,
sid: &str, sid: &str,
config: &Arc<Config>, config: &Arc<Config>,
is_private: bool, chat_id: i64,
) -> Result<String> { ) -> Result<String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
@@ -149,7 +81,6 @@ pub async fn run_openai_with_tools(
if !resp_raw.status().is_success() { if !resp_raw.status().is_success() {
let status = resp_raw.status(); let status = resp_raw.status();
let body_text = resp_raw.text().await.unwrap_or_default(); let body_text = resp_raw.text().await.unwrap_or_default();
// dump messages for debugging
for (i, m) in messages.iter().enumerate() { for (i, m) in messages.iter().enumerate() {
let role = m["role"].as_str().unwrap_or("?"); let role = m["role"].as_str().unwrap_or("?");
let content_len = m["content"].as_str().map(|s| s.len()).unwrap_or(0); let content_len = m["content"].as_str().map(|s| s.len()).unwrap_or(0);
@@ -162,15 +93,7 @@ pub async fn run_openai_with_tools(
} }
let mut resp = resp_raw; let mut resp = resp_raw;
let token = bot.token().to_owned();
let raw_chat_id = chat_id.0;
let draft_id: i64 = 1;
let mut use_draft = is_private; // sendMessageDraft only works in private chats
let mut msg_id: Option<teloxide::types::MessageId> = None;
let mut accumulated = String::new(); let mut accumulated = String::new();
let mut last_edit = Instant::now();
let mut buffer = String::new(); let mut buffer = String::new();
let mut done = false; let mut done = false;
@@ -206,14 +129,12 @@ pub async fn run_openai_with_tools(
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
let delta = &json["choices"][0]["delta"]; let delta = &json["choices"][0]["delta"];
// handle content delta
if let Some(content) = delta["content"].as_str() { if let Some(content) = delta["content"].as_str() {
if !content.is_empty() { if !content.is_empty() {
accumulated.push_str(content); accumulated.push_str(content);
} }
} }
// handle tool call delta
if let Some(tc_arr) = delta["tool_calls"].as_array() { if let Some(tc_arr) = delta["tool_calls"].as_array() {
has_tool_calls = true; has_tool_calls = true;
for tc in tc_arr { for tc in tc_arr {
@@ -237,70 +158,15 @@ pub async fn run_openai_with_tools(
} }
} }
// display update (only when there's content to show) if !accumulated.is_empty() {
if accumulated.is_empty() { let _ = output.stream_update(&accumulated).await;
continue;
} }
{
let interval = if use_draft {
DRAFT_INTERVAL_MS
} else {
EDIT_INTERVAL_MS
};
if last_edit.elapsed().as_millis() < interval as u128 {
continue;
}
let display = if use_draft {
truncate_at_char_boundary(&accumulated, TG_MSG_LIMIT).to_string()
} else {
truncate_for_display(&accumulated)
};
if use_draft {
match send_message_draft(
&client, &token, raw_chat_id, draft_id, &display,
)
.await
{
Ok(_) => {
last_edit = Instant::now();
}
Err(e) => {
warn!("sendMessageDraft failed, falling back: {e:#}");
use_draft = false;
if let Ok(sent) =
bot.send_message(chat_id, &display).await
{
msg_id = Some(sent.id);
last_edit = Instant::now();
}
}
}
} else if let Some(id) = msg_id {
if bot
.edit_message_text(chat_id, id, &display)
.await
.is_ok()
{
last_edit = Instant::now();
}
} else if let Ok(sent) =
bot.send_message(chat_id, &display).await
{
msg_id = Some(sent.id);
last_edit = Instant::now();
}
} // end display block
} }
} }
} }
// decide what to do based on response type // decide what to do based on response type
if has_tool_calls && !tool_calls.is_empty() { if has_tool_calls && !tool_calls.is_empty() {
// append assistant message with tool calls
let tc_json: Vec<serde_json::Value> = tool_calls let tc_json: Vec<serde_json::Value> = tool_calls
.iter() .iter()
.map(|tc| { .map(|tc| {
@@ -322,15 +188,14 @@ pub async fn run_openai_with_tools(
}); });
messages.push(assistant_msg); messages.push(assistant_msg);
// execute each tool
for tc in &tool_calls { for tc in &tool_calls {
info!(tool = %tc.name, "executing tool call"); info!(tool = %tc.name, "executing tool call");
let _ = bot let _ = output
.send_message(chat_id, format!("[{}({})]", tc.name, truncate_at_char_boundary(&tc.arguments, 100))) .status(&format!("[{}({})]", tc.name, truncate_at_char_boundary(&tc.arguments, 100)))
.await; .await;
let result = let result =
execute_tool(&tc.name, &tc.arguments, state, bot, chat_id, sid, config) execute_tool(&tc.name, &tc.arguments, state, output, sid, config, chat_id)
.await; .await;
messages.push(serde_json::json!({ messages.push(serde_json::json!({
@@ -340,358 +205,22 @@ pub async fn run_openai_with_tools(
})); }));
} }
// clear display state for next round
tool_calls.clear(); tool_calls.clear();
// loop back to call API again
continue; continue;
} }
// content response — send final result // strip timestamps that LLM copies from our message format
if !accumulated.is_empty() { let cleaned = strip_leading_timestamp(&accumulated).to_string();
send_final_result(bot, chat_id, msg_id, use_draft, &accumulated).await;
if !cleaned.is_empty() {
let _ = output.finalize(&cleaned).await;
} }
return Ok(accumulated); return Ok(cleaned);
} }
} }
// ── claude bridge (streaming) ─────────────────────────────────────── pub fn build_system_prompt(summary: &str, persona: &str, memory_slots: &[(i32, String, String)], inner_state: &str) -> serde_json::Value {
pub async fn invoke_claude_streaming(
sid: &str,
prompt: &str,
known: bool,
bot: &Bot,
chat_id: ChatId,
) -> Result<String> {
if known {
return run_claude_streaming(&["--resume", sid], prompt, bot, chat_id).await;
}
match run_claude_streaming(&["--resume", sid], prompt, bot, chat_id).await {
Ok(out) => {
info!(%sid, "resumed existing session");
Ok(out)
}
Err(e) => {
warn!(%sid, "resume failed ({e:#}), creating new session");
run_claude_streaming(&["--session-id", sid], prompt, bot, chat_id).await
}
}
}
pub async fn run_claude_streaming(
extra_args: &[&str],
prompt: &str,
bot: &Bot,
chat_id: ChatId,
) -> Result<String> {
let mut args: Vec<&str> = vec![
"--dangerously-skip-permissions",
"-p",
"--output-format",
"stream-json",
"--verbose",
];
args.extend(extra_args);
args.push(prompt);
let mut child = Command::new("claude")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut lines = tokio::io::BufReader::new(stdout).lines();
// sendMessageDraft for native streaming, with editMessageText fallback
let http = reqwest::Client::new();
let token = bot.token().to_owned();
let raw_chat_id = chat_id.0;
let draft_id: i64 = 1;
let mut use_draft = true;
let mut msg_id: Option<teloxide::types::MessageId> = None;
let mut last_sent_text = String::new();
let mut last_edit = Instant::now();
let mut final_result = String::new();
let mut is_error = false;
let mut tool_status = String::new();
while let Ok(Some(line)) = lines.next_line().await {
let event: StreamEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
match event.event_type.as_str() {
"assistant" => {
if let Some(amsg) = &event.message {
// determine display content
let (display_raw, new_text) =
if let Some(status) = extract_tool_use(amsg) {
tool_status = format!("[{status}]");
let d = if last_sent_text.is_empty() {
tool_status.clone()
} else {
format!("{last_sent_text}\n\n{tool_status}")
};
(d, None)
} else {
let text = extract_text(amsg);
if text.is_empty() || text == last_sent_text {
continue;
}
let interval = if use_draft {
DRAFT_INTERVAL_MS
} else {
EDIT_INTERVAL_MS
};
if last_edit.elapsed().as_millis() < interval as u128 {
continue;
}
tool_status.clear();
(text.clone(), Some(text))
};
let display = if use_draft {
// draft mode: no cursor — cursor breaks monotonic text growth
truncate_at_char_boundary(&display_raw, TG_MSG_LIMIT).to_string()
} else {
truncate_for_display(&display_raw)
};
if use_draft {
match send_message_draft(
&http, &token, raw_chat_id, draft_id, &display,
)
.await
{
Ok(_) => {
if let Some(t) = new_text {
last_sent_text = t;
}
last_edit = Instant::now();
}
Err(e) => {
warn!("sendMessageDraft failed, falling back: {e:#}");
use_draft = false;
if let Ok(sent) =
bot.send_message(chat_id, &display).await
{
msg_id = Some(sent.id);
if let Some(t) = new_text {
last_sent_text = t;
}
last_edit = Instant::now();
}
}
}
} else if let Some(id) = msg_id {
if bot
.edit_message_text(chat_id, id, &display)
.await
.is_ok()
{
if let Some(t) = new_text {
last_sent_text = t;
}
last_edit = Instant::now();
}
} else if let Ok(sent) =
bot.send_message(chat_id, &display).await
{
msg_id = Some(sent.id);
if let Some(t) = new_text {
last_sent_text = t;
}
last_edit = Instant::now();
}
}
}
"result" => {
final_result = event.result.unwrap_or_default();
is_error = event.is_error;
}
_ => {}
}
}
// read stderr before waiting (in case child already exited)
let stderr_handle = child.stderr.take();
let status = child.wait().await;
// collect stderr for diagnostics
let stderr_text = if let Some(mut se) = stderr_handle {
let mut buf = String::new();
let _ = tokio::io::AsyncReadExt::read_to_string(&mut se, &mut buf).await;
buf
} else {
String::new()
};
// determine error: explicit is_error from stream, or non-zero exit with no result
let has_error = is_error
|| (final_result.is_empty()
&& status.as_ref().map(|s| !s.success()).unwrap_or(true));
if has_error {
let err_detail = if !final_result.is_empty() {
final_result.clone()
} else if !stderr_text.is_empty() {
stderr_text.trim().to_string()
} else {
format!("claude exited: {:?}", status)
};
if !use_draft {
if let Some(id) = msg_id {
let _ = bot
.edit_message_text(chat_id, id, format!("[error] {err_detail}"))
.await;
}
}
anyhow::bail!("{err_detail}");
}
if final_result.is_empty() {
return Ok(final_result);
}
send_final_result(bot, chat_id, msg_id, use_draft, &final_result).await;
Ok(final_result)
}
// ── openai-compatible backend (streaming) ──────────────────────────
pub async fn run_openai_streaming(
endpoint: &str,
model: &str,
api_key: &str,
messages: &[serde_json::Value],
bot: &Bot,
chat_id: ChatId,
) -> Result<String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap();
let url = format!("{}/chat/completions", endpoint.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"messages": messages,
"stream": true,
});
let mut resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&body)
.send()
.await?
.error_for_status()?;
let token = bot.token().to_owned();
let raw_chat_id = chat_id.0;
let draft_id: i64 = 1;
let mut use_draft = true;
let mut msg_id: Option<teloxide::types::MessageId> = None;
let mut accumulated = String::new();
let mut last_edit = Instant::now();
let mut buffer = String::new();
let mut done = false;
while let Some(chunk) = resp.chunk().await? {
if done {
break;
}
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(':') {
continue;
}
let data = match trimmed.strip_prefix("data: ") {
Some(d) => d,
None => continue,
};
if data.trim() == "[DONE]" {
done = true;
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(content) = json["choices"][0]["delta"]["content"].as_str() {
if content.is_empty() {
continue;
}
accumulated.push_str(content);
let interval = if use_draft {
DRAFT_INTERVAL_MS
} else {
EDIT_INTERVAL_MS
};
if last_edit.elapsed().as_millis() < interval as u128 {
continue;
}
let display = if use_draft {
truncate_at_char_boundary(&accumulated, TG_MSG_LIMIT).to_string()
} else {
truncate_for_display(&accumulated)
};
if use_draft {
match send_message_draft(
&client, &token, raw_chat_id, draft_id, &display,
)
.await
{
Ok(_) => {
last_edit = Instant::now();
}
Err(e) => {
warn!("sendMessageDraft failed, falling back: {e:#}");
use_draft = false;
if let Ok(sent) = bot.send_message(chat_id, &display).await {
msg_id = Some(sent.id);
last_edit = Instant::now();
}
}
}
} else if let Some(id) = msg_id {
if bot.edit_message_text(chat_id, id, &display).await.is_ok() {
last_edit = Instant::now();
}
} else if let Ok(sent) = bot.send_message(chat_id, &display).await {
msg_id = Some(sent.id);
last_edit = Instant::now();
}
}
}
}
}
if accumulated.is_empty() {
return Ok(accumulated);
}
send_final_result(bot, chat_id, msg_id, use_draft, &accumulated).await;
Ok(accumulated)
}
pub fn build_system_prompt(summary: &str, persona: &str, memory_slots: &[(i32, String)], inner_state: &str) -> serde_json::Value {
let mut text = if persona.is_empty() { let mut text = if persona.is_empty() {
String::from("你是一个AI助手。") String::from("你是一个AI助手。")
} else { } else {
@@ -708,17 +237,42 @@ pub fn build_system_prompt(summary: &str, persona: &str, memory_slots: &[(i32, S
); );
if !memory_slots.is_empty() { if !memory_slots.is_empty() {
text.push_str("\n\n## 持久记忆(跨会话保留)\n"); text.push_str(
for (nr, content) in memory_slots { "\n\n## 持久记忆(跨会话保留,你可以用 update_memory 工具管理)\n\
槽位 0-9: 事实(位置/偏好/习惯)\n\
槽位 10-19: 重要时刻\n\
槽位 20-29: 情感经验\n\
槽位 30-39: 你自己的成长\n\
槽位 40-99: 自由使用\n\
发现重要信息时主动更新,过时的要清理。\n\n",
);
for (nr, content, updated_at) in memory_slots {
if updated_at.is_empty() {
text.push_str(&format!("[{nr}] {content}\n")); text.push_str(&format!("[{nr}] {content}\n"));
} else {
text.push_str(&format!("[{nr}] {content} ({updated_at})\n"));
}
} }
} }
if !inner_state.is_empty() { if !inner_state.is_empty() {
text.push_str("\n\n## 你的内在状态\n"); text.push_str("\n\n## 你的内在状态(你可以用 update_inner_state 工具更新)\n");
text.push_str(inner_state); text.push_str(inner_state);
} }
// inject context file if present (e.g. /data/noc/context.md)
let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
let context_path = std::path::Path::new(&config_path)
.parent()
.unwrap_or(std::path::Path::new("."))
.join("context.md");
if let Ok(ctx) = std::fs::read_to_string(&context_path) {
if !ctx.trim().is_empty() {
text.push_str("\n\n## 运行环境\n");
text.push_str(ctx.trim());
}
}
if !summary.is_empty() { if !summary.is_empty() {
text.push_str("\n\n## 之前的对话总结\n"); text.push_str("\n\n## 之前的对话总结\n");
text.push_str(summary); text.push_str(summary);

View File

@@ -4,17 +4,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use teloxide::prelude::*;
use teloxide::types::InputFile;
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tracing::{error, info, warn}; use tracing::{info, warn};
use crate::config::{BackendConfig, Config}; use crate::config::Config;
use crate::display::truncate_at_char_boundary; use crate::display::truncate_at_char_boundary;
use crate::output::Output;
use crate::state::AppState; use crate::state::AppState;
use crate::stream::{build_system_prompt, run_openai_streaming};
// ── subagent & tool call ─────────────────────────────────────────── // ── subagent & tool call ───────────────────────────────────────────
@@ -200,6 +198,57 @@ pub fn discover_tools() -> serde_json::Value {
} }
} }
}), }),
serde_json::json!({
"type": "function",
"function": {
"name": "run_shell",
"description": "在服务器上执行 shell 命令。可执行任意 bash 命令,支持管道和重定向。超时 60 秒。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的 shell 命令"},
"timeout": {"type": "integer", "description": "超时秒数(默认 60最大 300"}
},
"required": ["command"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "run_python",
"description": "用 uv run 执行 Python 代码。支持 inline dependencies通过 deps 参数自动安装),无需手动管理虚拟环境。超时 120 秒。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "要执行的 Python 代码"},
"deps": {
"type": "array",
"items": {"type": "string"},
"description": "依赖包列表(如 [\"requests\", \"pandas\"]),会自动通过 uv 安装"
},
"timeout": {"type": "integer", "description": "超时秒数(默认 120最大 300"}
},
"required": ["code"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "call_gitea_api",
"description": "调用 Gitea REST API。以 noc_bot 身份操作,拥有 admin 权限。可管理 repo、issue、PR、comment、webhook 等一切。",
"parameters": {
"type": "object",
"properties": {
"method": {"type": "string", "description": "HTTP method: GET, POST, PATCH, PUT, DELETE"},
"path": {"type": "string", "description": "API path after /api/v1/, e.g. repos/noc/myrepo/issues"},
"body": {"type": "object", "description": "Optional JSON body for POST/PATCH/PUT"}
},
"required": ["method", "path"]
}
}
}),
]; ];
// discover script tools // discover script tools
@@ -245,10 +294,10 @@ pub async fn execute_tool(
name: &str, name: &str,
arguments: &str, arguments: &str,
state: &Arc<AppState>, state: &Arc<AppState>,
bot: &Bot, output: &mut dyn Output,
chat_id: ChatId,
sid: &str, sid: &str,
config: &Arc<Config>, config: &Arc<Config>,
chat_id: i64,
) -> String { ) -> String {
let args: serde_json::Value = match serde_json::from_str(arguments) { let args: serde_json::Value = match serde_json::from_str(arguments) {
Ok(v) => v, Ok(v) => v,
@@ -259,7 +308,7 @@ pub async fn execute_tool(
"spawn_agent" => { "spawn_agent" => {
let id = args["id"].as_str().unwrap_or("agent"); let id = args["id"].as_str().unwrap_or("agent");
let task = args["task"].as_str().unwrap_or(""); let task = args["task"].as_str().unwrap_or("");
spawn_agent(id, task, state, bot, chat_id, sid, config).await spawn_agent(id, task, state, output, sid, config).await
} }
"agent_status" => { "agent_status" => {
let id = args["id"].as_str().unwrap_or(""); let id = args["id"].as_str().unwrap_or("");
@@ -279,13 +328,9 @@ pub async fn execute_tool(
if !path.is_file() { if !path.is_file() {
return format!("Not a file: {path_str}"); return format!("Not a file: {path_str}");
} }
let input_file = InputFile::file(path); match output.send_file(path, caption).await {
let mut req = bot.send_document(chat_id, input_file); Ok(true) => format!("File sent: {path_str}"),
if !caption.is_empty() { Ok(false) => format!("File sending not supported in this context: {path_str}"),
req = req.caption(caption);
}
match req.await {
Ok(_) => format!("File sent: {path_str}"),
Err(e) => format!("Failed to send file: {e:#}"), Err(e) => format!("Failed to send file: {e:#}"),
} }
} }
@@ -306,7 +351,7 @@ pub async fn execute_tool(
Ok(next) => { Ok(next) => {
let next_str = next.format("%Y-%m-%d %H:%M:%S").to_string(); let next_str = next.format("%Y-%m-%d %H:%M:%S").to_string();
let id = state let id = state
.add_timer(chat_id.0, label, schedule, &next_str) .add_timer(chat_id, label, schedule, &next_str)
.await; .await;
format!("Timer #{id} set: \"{label}\" → next fire at {next_str}") format!("Timer #{id} set: \"{label}\" → next fire at {next_str}")
} }
@@ -314,7 +359,7 @@ pub async fn execute_tool(
} }
} }
"list_timers" => { "list_timers" => {
let timers = state.list_timers(Some(chat_id.0)).await; let timers = state.list_timers(Some(chat_id)).await;
if timers.is_empty() { if timers.is_empty() {
"No active timers.".to_string() "No active timers.".to_string()
} else { } else {
@@ -350,6 +395,158 @@ pub async fn execute_tool(
Err(e) => format!("Error: {e}"), Err(e) => format!("Error: {e}"),
} }
} }
"run_shell" => {
let cmd = args["command"].as_str().unwrap_or("");
if cmd.is_empty() {
return "Error: command is required".to_string();
}
let timeout_secs = args["timeout"].as_u64().unwrap_or(60).min(300);
info!(cmd = %cmd, "run_shell");
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Command::new("bash")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match result {
Ok(Ok(out)) => {
let mut s = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.is_empty() {
if !s.is_empty() {
s.push_str("\n[stderr]\n");
}
s.push_str(&stderr);
}
let exit = out.status.code().unwrap_or(-1);
if s.len() > 8000 {
s = format!("{}...(truncated)", &s[..8000]);
}
if exit != 0 {
s.push_str(&format!("\n[exit={exit}]"));
}
if s.is_empty() {
format!("(exit={exit})")
} else {
s
}
}
Ok(Err(e)) => format!("exec error: {e}"),
Err(_) => format!("timeout after {timeout_secs}s"),
}
}
"run_python" => {
let code = args["code"].as_str().unwrap_or("");
if code.is_empty() {
return "Error: code is required".to_string();
}
let timeout_secs = args["timeout"].as_u64().unwrap_or(120).min(300);
let deps = args["deps"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Build uv run command with inline script metadata for deps
let script = if deps.is_empty() {
code.to_string()
} else {
let dep_lines: String = deps.iter().map(|d| format!("# \"{d}\",\n")).collect();
format!(
"# /// script\n# [project]\n# dependencies = [\n{dep_lines}# ]\n# ///\n{code}"
)
};
// Write script to temp file
let tmp = format!("/tmp/noc_py_{}.py", std::process::id());
if let Err(e) = std::fs::write(&tmp, &script) {
return format!("Failed to write temp script: {e}");
}
info!(deps = ?deps, "run_python");
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Command::new("uv")
.args(["run", &tmp])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
let _ = std::fs::remove_file(&tmp);
match result {
Ok(Ok(out)) => {
let mut s = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.is_empty() {
if !s.is_empty() {
s.push_str("\n[stderr]\n");
}
s.push_str(&stderr);
}
let exit = out.status.code().unwrap_or(-1);
if s.len() > 8000 {
s = format!("{}...(truncated)", &s[..8000]);
}
if exit != 0 {
s.push_str(&format!("\n[exit={exit}]"));
}
if s.is_empty() {
format!("(exit={exit})")
} else {
s
}
}
Ok(Err(e)) => format!("exec error: {e} (is uv installed?)"),
Err(_) => format!("timeout after {timeout_secs}s"),
}
}
"call_gitea_api" => {
let method = args["method"].as_str().unwrap_or("GET").to_uppercase();
let path = args["path"].as_str().unwrap_or("").trim_start_matches('/');
let body = args.get("body");
let gitea_config = match &config.gitea {
Some(c) => c,
None => return "Gitea not configured".to_string(),
};
let url = format!("{}/api/v1/{}", gitea_config.url.trim_end_matches('/'), path);
let client = reqwest::Client::new();
let mut req = match method.as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => return format!("Unsupported method: {method}"),
};
req = req.header("Authorization", format!("token {}", gitea_config.token));
if let Some(b) = body {
req = req.json(b);
}
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
// Truncate large responses
let text = if text.len() > 4000 {
format!("{}...(truncated)", &text[..4000])
} else {
text
};
format!("HTTP {status}\n{text}")
}
Err(e) => format!("Request failed: {e:#}"),
}
}
"gen_voice" => { "gen_voice" => {
let text = args["text"].as_str().unwrap_or(""); let text = args["text"].as_str().unwrap_or("");
if text.is_empty() { if text.is_empty() {
@@ -368,9 +565,9 @@ pub async fn execute_tool(
let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
let path = Path::new(&path_str); let path = Path::new(&path_str);
if path.exists() { if path.exists() {
let input_file = InputFile::file(path); match output.send_file(path, "").await {
match bot.send_voice(chat_id, input_file).await { Ok(true) => format!("语音已发送: {path_str}"),
Ok(_) => format!("语音已发送: {path_str}"), Ok(false) => format!("语音生成成功但当前通道不支持发送文件: {path_str}"),
Err(e) => format!("语音生成成功但发送失败: {e:#}"), Err(e) => format!("语音生成成功但发送失败: {e:#}"),
} }
} else { } else {
@@ -394,10 +591,9 @@ pub async fn spawn_agent(
id: &str, id: &str,
task: &str, task: &str,
state: &Arc<AppState>, state: &Arc<AppState>,
bot: &Bot, output: &dyn Output,
chat_id: ChatId, _sid: &str,
sid: &str, _config: &Arc<Config>,
config: &Arc<Config>,
) -> String { ) -> String {
// check if already exists // check if already exists
if state.agents.read().await.contains_key(id) { if state.agents.read().await.contains_key(id) {
@@ -415,13 +611,13 @@ pub async fn spawn_agent(
}; };
let pid = child.id(); let pid = child.id();
let output = Arc::new(tokio::sync::RwLock::new(String::new())); let agent_output = Arc::new(tokio::sync::RwLock::new(String::new()));
let completed = Arc::new(AtomicBool::new(false)); let completed = Arc::new(AtomicBool::new(false));
let exit_code = Arc::new(tokio::sync::RwLock::new(None)); let exit_code = Arc::new(tokio::sync::RwLock::new(None));
let agent = Arc::new(SubAgent { let agent = Arc::new(SubAgent {
task: task.to_string(), task: task.to_string(),
output: output.clone(), output: agent_output.clone(),
completed: completed.clone(), completed: completed.clone(),
exit_code: exit_code.clone(), exit_code: exit_code.clone(),
pid, pid,
@@ -429,15 +625,10 @@ pub async fn spawn_agent(
state.agents.write().await.insert(id.to_string(), agent); state.agents.write().await.insert(id.to_string(), agent);
// background task: collect output and wakeup on completion // background task: collect output
let out = output.clone(); let out = agent_output.clone();
let done = completed.clone(); let done = completed.clone();
let ecode = exit_code.clone(); let ecode = exit_code.clone();
let bot_c = bot.clone();
let chat_id_c = chat_id;
let state_c = state.clone();
let config_c = config.clone();
let sid_c = sid.to_string();
let id_c = id.to_string(); let id_c = id.to_string();
tokio::spawn(async move { tokio::spawn(async move {
@@ -456,75 +647,12 @@ pub async fn spawn_agent(
done.store(true, Ordering::SeqCst); done.store(true, Ordering::SeqCst);
info!(agent = %id_c, "agent completed, exit={code:?}"); info!(agent = %id_c, "agent completed, exit={code:?}");
// wakeup: inject result and trigger LLM
let result = out.read().await.clone();
let result_short = truncate_at_char_boundary(&result, 4000);
let wakeup = format!(
"[Agent '{id_c}' 执行完成 (exit={})]\n{result_short}",
code.unwrap_or(-1)
);
if let Err(e) = agent_wakeup(
&config_c, &state_c, &bot_c, chat_id_c, &sid_c, &wakeup, &id_c,
)
.await
{
error!(agent = %id_c, "wakeup failed: {e:#}");
let _ = bot_c
.send_message(chat_id_c, format!("[agent wakeup error] {e:#}"))
.await;
}
}); });
let _ = output.status(&format!("Agent '{id}' spawned (pid={pid:?})")).await;
format!("Agent '{id}' spawned (pid={pid:?})") format!("Agent '{id}' spawned (pid={pid:?})")
} }
pub async fn agent_wakeup(
config: &Config,
state: &AppState,
bot: &Bot,
chat_id: ChatId,
sid: &str,
wakeup_msg: &str,
agent_id: &str,
) -> Result<()> {
match &config.backend {
BackendConfig::OpenAI {
endpoint,
model,
api_key,
} => {
state.push_message(sid, "user", wakeup_msg).await;
let conv = state.load_conv(sid).await;
let persona = state.get_config("persona").await.unwrap_or_default();
let memory_slots = state.get_memory_slots().await;
let inner = state.get_inner_state().await;
let system_msg = build_system_prompt(&conv.summary, &persona, &memory_slots, &inner);
let mut api_messages = vec![system_msg];
api_messages.extend(conv.messages);
info!(agent = %agent_id, "wakeup: sending {} messages to LLM", api_messages.len());
let response =
run_openai_streaming(endpoint, model, api_key, &api_messages, bot, chat_id)
.await?;
if !response.is_empty() {
state.push_message(sid, "assistant", &response).await;
}
Ok(())
}
_ => {
let _ = bot
.send_message(chat_id, format!("[Agent '{agent_id}' done]\n{wakeup_msg}"))
.await;
Ok(())
}
}
}
pub async fn check_agent_status(id: &str, state: &AppState) -> String { pub async fn check_agent_status(id: &str, state: &AppState) -> String {
let agents = state.agents.read().await; let agents = state.agents.read().await;
match agents.get(id) { match agents.get(id) {