diff --git a/THRUM.md b/THRUM.md index 5ee100af..a189dd8d 100644 --- a/THRUM.md +++ b/THRUM.md @@ -345,7 +345,7 @@ the bloom broke off | field | type | meaning | |---|---|---| | `message` | `string` | what broke | -| `code` | `string?` | no-nurse when no nurse here or on a trusted peer grows the model; cannot-grow-on from a nurse that grows only from new words; empty-prompt for a prompt with neither words nor a graft | +| `code` | `string?` | no-nurse when no nurse here or on a trusted peer grows the model; nurse-gone from humd when the peer growing it left the ensemble mid-bloom, so it may be grown again; cannot-grow-on from a nurse that grows only from new words; empty-prompt for a prompt with neither words nor a graft | | `subtype` | `string?` | the cell's own word for what broke | | `nectar` | `any?` | what the bloom took before it broke off | @@ -755,14 +755,20 @@ a bee only through its hive, and knows it only by its key. The ensemble layer (peer humds, gossip) and the on-chain layer (`HumdRegistry`) are additive opt-ins on top. +Until a breath admits it, a connection is heard for nothing but its +`hello`: any other tone is answered with an `echo` whose `ok` is false +under its rid, and an `echo` or a `log` is dropped. One key is one bee: +when a second connection signed by the same key is admitted, humd closes +the first, and frees what it held as a closing connection does. + The handshake doesn't distinguish where the bytes came from. A hello arriving on humd's local Unix socket and a hello bridged through the ensemble from a peer humd hit the same code path. Local-dev runs, local-prod-with-systemd setups, and distributed mesh deployments all -exercise the same `chi:"hello"` shape — see -[hives/foragers.md](../hives/) for the three deployment -paradigms (local-dev / local-prod / distributed) that share this -single protocol. +exercise the same `chi:"hello"` shape. See +[hives, Run a hive](./hives/README.md#run-a-hive) for the three +deployment shapes (local dev, the ensemble, a managed service) that +share this single protocol. ```json { @@ -858,6 +864,14 @@ with no `modelId` goes to any nurse with room, which grows it with a model of its own, and one no nurse grows is refused with `code: "no-nurse"`. +A bloom's tones go to the connections that prompted or attached under its +sid, and to no one else; a sid no connection holds reaches no one. A +`cleanup` frees what humd keeps for the sid, and so does the close of the +last connection that held it, so a forager that takes a sid up again on a +new connection `attach`es it first, and names its `cwd` again. When the +peer growing a bloom leaves the ensemble mid-bloom, humd breaks the bloom +off with `code: "nurse-gone"`, and its forager may prompt it again. + #### Graft A prompt's `graft` is the blooms of its sigil before it, oldest first, as @@ -916,7 +930,11 @@ humd → nurse: chi:"tendril-result" { sid, callId, output, isError } A reach no bee can serve is answered by humd itself, with a `tendril-result` whose `isError` is true and whose `output` says why, so -the cell does not wait for an answer that cannot come. +the cell does not wait for an answer that cannot come. A bee that cannot +or will not grasp a reach answers it the same way. An `error` sent under +a reach's `callId` reaches the cell as that failed grasp too, its +`message` as the `output`, its `code` and `subtype` as the `result`, and +its `ext` whole. #### `bash` @@ -1125,7 +1143,10 @@ enter, a revoked key, an incompatible protocol) and advises on the rest. An advisory reaches the bee as a `log` tone whose `fields.advisory` is one of these codes, and hum's client raises some itself before a tone leaves the bee. humd keeps them per bee in `advisories.json` in the state -root, and `hum doctor` lists them. +root, and `hum doctor` lists them. A refusal is kept there too, as +`refused` with its reason, when the hello names a bee entered on this +humd, so `hum bee --list` says why a bee that keeps dialling is not +nestled; that bee is not heard from as having failed to return. @@ -1141,6 +1162,7 @@ root, and `hum doctor` lists them. | `reconnect-storm` | humd | the bee said hello 10 times within 10 minutes | | `did-not-return` | humd | the bee was nestled when humd stopped and had not come back 2 minutes after humd started | | `cwd-not-hum` | humd | a prompt's `cwd` is not a `hum://` address, and was dropped | +| `refused` | humd | humd refused the bee's hello, and why; the bee heard it in the hello's echo | @@ -1214,8 +1236,9 @@ interrupts and terminations: its life is the pipe's. A tone can also travel whole, as one message, with no line to split. hum's gRPC hive carries it as `Tone { chi, sid, rid, body }`: the JSON of -the tone as `body` bytes, with `chi`, `sid` and `rid` lifted out so the -bridge can multiplex without parsing a body. Inside one humd, the ensemble +the tone as `body` bytes, with `chi`, `sid` and `rid` lifted out so a +client can match what comes back without parsing a body; `body` is +authoritative, and the bridge reads it. Inside one humd, the ensemble hands tones to the kernel as values, and `peer-add` and `peer-remove` travel only that way. Either way the tone is the same object, and a medium that carries it whole has no framing to specify. @@ -1276,10 +1299,10 @@ link, or the wire itself. The wire, as hum's client speaks it: - [`hum.thrum`](./hum.thrum): the thrum itself, its envelope, range, shapes, kinds, patterns and advisories. The file wins on what a tone is. - [GLOSSARY.md](./GLOSSARY.md): what each word means. -- [`thrum-core`](../thrum-core): the Rust reading of it (chi enum, views, helpers), held to the file. -- [`hum-thrum`](../hum-thrum): the client, sans-IO at its core, and the link. -- [`thrum-clients`](../thrum-clients): the generated client for TS, Python, Go and C. -- [`codegen`](../codegen): what reads the file out, into the clients and into this document. -- [`ensemble/README.md`](../ensemble/README.md): inter-humd routing. -- [`hives/foragers.md`](../hives/foragers.md): typology and propensity axes. +- [`thrum-core`](./thrum-core): the Rust reading of it (chi enum, views, helpers), held to the file. +- [`hum-thrum`](./hum-thrum): the client, sans-IO at its core, and the link. +- [`thrum-clients`](./thrum-clients): the generated client for TS, Python, Go and C. +- [`codegen`](./codegen): what reads the file out, into the clients and into this document. +- [`ensemble/README.md`](./ensemble/README.md): inter-humd routing. +- [`hives/README.md`](./hives/README.md): roles, propensity axes, and how to build and run a hive. - [adiled.github.io/hum](https://adiled.github.io/hum/): the docs site. diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 887f2c6d..553a2059 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -350,8 +350,10 @@ impl Ensemble { pub fn remove_peer(&self, id: &Hid) { self.kad.forget(id); - if let Some(p) = self.peers.write().remove(id) { + let gone = self.peers.write().remove(id); + if let Some(p) = gone { p.conn.close(); + self.inbox.publish(peer_remove_tone(&self.me, id)); } } @@ -1789,4 +1791,21 @@ mod invariants { "a dead peer must not route" ); } + + #[tokio::test] + async fn a_removed_peer_is_told_to_the_kernel_once() { + let me = Hid::random_humd(); + let peer = Hid::random_humd(); + let ensemble = Ensemble::new(me); + let (mine, _theirs) = InMemoryEndpoint::pair_concrete(me, caps(), peer, caps()); + ensemble.add_peer(mine); + let mut inbox = ensemble.subscribe(); + + ensemble.remove_peer(&peer); + ensemble.remove_peer(&peer); + let told = inbox.try_recv().expect("the kernel hears the peer is gone"); + assert_eq!(told["chi"], "peer-remove"); + assert_eq!(told["humd_id"], hex::encode(peer.as_bytes())); + assert!(inbox.try_recv().is_err(), "a peer already gone is not told again"); + } } diff --git a/hives/README.md b/hives/README.md index 77b5d719..778b9c52 100644 --- a/hives/README.md +++ b/hives/README.md @@ -9,7 +9,7 @@ description: "how to build a hive, install it on a humd, and the bees that humd ## The vocabulary, once -A humd is a coordination node: it admits bees, routes between them and gossips with its peers, and hosts no cells. A **nest** is the cells a hive hosts, which it turns on by using [`hum-nest`](../hum-nest). A **hive** is the kind, such as `claude-cli`, `openai-server`, or `paid-oracle`: a directory with an `Orchfile`. It does not run on its own. A **bee** is a participant: a key its hive entered, and whatever signs a hello with that key: a process on this machine, a runtime hosting many bees, a service on a phone. A bee does one job, its **role**, set when its hive enters it. A **forager** prompts: it brings work in from an outside wire, whether that wire is HTTP, SMS, a smart contract, or a paid API, and carries the bloom back. A **nurse** grows blooms: it accepts `chi:"prompt"` and unfurls the bloom in its cells until it wilts. A **builder** lends its host: it runs the thrum's `bash` for the blooms that build there. An **attendant** serves the fixed, named tools it lists. A hive that wants two jobs enters two bees. While a bee is mid-handshake it is a **nestler**, and once registered it is **nestled**. +A humd is a coordination node: it admits bees, routes between them and gossips with its peers, and hosts no cells. A **nest** is the cells a hive hosts, which it turns on by using [`hum-nest`](../hum-nest). A **hive** is the kind, such as `claude-cli`, `openai-server`, or `paid-oracle`: a directory with an `Orchfile`. It does not run on its own. A **bee** is a participant: a key its hive entered, and whatever signs a hello with that key: a process on this machine, a runtime hosting many bees, a service on a phone. A bee does one job, its **role**, set when its hive enters it. A **forager** prompts: it brings work in from an outside wire, whether that wire is HTTP, gRPC, SMS or a DTN bundle, and carries the bloom back. A **nurse** grows blooms: it accepts `chi:"prompt"` and unfurls the bloom in its cells until it wilts. A **builder** lends its host: it runs the thrum's `bash` for the blooms that build there. An **attendant** serves the fixed, named tools it lists, and the scents of the models it names. A hive that wants two jobs enters two bees. While a bee is mid-handshake it is a **nestler**, and once admitted it is **nestled**. A honeybee on the plant pot by your bed is still a bee; you just need to know what kind it is to understand it. So it goes here. A humd understands a bee only through its hive, and **installing** a hive on a humd is what teaches the humd that kind. The hive then **enters** bees, signing them in one key each, and a bee nestles by signing its hello with its key, wherever it lands from. humd refuses a hello that is unsigned, from a key never entered, or naming a hive it was never taught. humd never links a hive in at build time and hum never starts a bee; bees attach at runtime, in any language, run however their hive likes. @@ -18,31 +18,34 @@ A honeybee on the plant pot by your bed is still a bee; you just need to know wh | hive | role | binary | wire it speaks | |---|---|---|---| | [`claude-cli`](claude-cli) | nurse | `claude-cli-nurse` | `claude -p` stream-json over a pipe | -| [`claude-repl`](claude-repl) | nurse | `claude-repl-nurse` | claude REPL over a PTY | -| [`openai-server`](openai-server) | forager, nurse, attendant | `openai-server` | OpenAI `/v1/chat/completions`, `/v1/responses`, `/v1/completions`, `/v1/embeddings` | -| [`anthropic-server`](anthropic-server) | forager | `anthropic-server` | Anthropic `/v1/messages` | -| [`ollama-server`](ollama-server) | forager, nurse | `ollama-server`, `ollama-nurse` | Ollama `/api/chat`, both ways | -| [`vercel-ai`](vercel-ai) | forager | `vercel-ai` | Vercel AI SDK `LanguageModelV3` | -| [`bash`](bash) | builder | `bash-builder` | a bash shell per sigil, on its own host | -| [`paid-oracle`](paid-oracle) | attendant | `paid-oracle` | x402 over a tendril, USDC per call | -| [`web2`](web2) | attendant | `web2` | OpenAPI and MCP, as one command-line tendril, `wrepl` | -| [`grpc`](grpc) | forager | `grpc-forager` | gRPC bidi stream | +| [`claude-repl`](claude-repl) | nurse | `claude-repl-nurse` | the `claude` REPL over a PTY | +| [`dwarfstar`](dwarfstar) | nurse, one bee per model | `dwarfstar-bee` | the DwarfStar engine (`antirez/ds4`) linked into the bee, on Metal | +| [`ollama-server`](ollama-server) | forager, nurse | `ollama-server`, `ollama-nurse` | Ollama `/api/chat` and `/api/generate`, both ways | +| [`openai-server`](openai-server) | forager, nurse, attendant | `node dist/index.js` | OpenAI `/v1/chat/completions`, `/v1/responses`, `/v1/completions`, `/v1/embeddings` in front, an OpenAI-compatible API behind | +| [`anthropic-server`](anthropic-server) | forager | `node dist/index.js` | Anthropic `/v1/messages`, streamed or not | +| [`vercel-ai`](vercel-ai) | forager | `createHum`, in the app's own process | Vercel AI SDK `LanguageModelV3` | +| [`grpc`](grpc) | forager | `grpc-forager` | gRPC bidi stream, tones carried whole | | [`twilio-sms`](twilio-sms) | forager | `twilio-sms` | Twilio Messaging webhook | | [`gsm-modem`](gsm-modem) | forager | `gsm-modem` | GSM AT-command serial SMS | -| [`bp7`](bp7) | forager | `bp7-forager` | Bundle Protocol v7 (DTN) | -| [`hum-nest`](../hum-nest) | library | n/a | `serve_nurse`: the nest, and the MCP its cells reach tools through | +| [`bp7`](bp7) | forager | `bp7-forager` | Bundle Protocol v7 (DTN) over UDP | +| [`wifi-p2p`](wifi-p2p) | forager | `WifiP2pBeeService`, an Android app | the thrum's stream over WiFi Direct TCP | +| [`bash`](bash) | builder | `bash-builder` | a bash shell per sigil, on its own host | +| [`paid-oracle`](paid-oracle) | attendant | `paid-oracle` | x402 over a tendril, USDC per call | +| [`web2`](web2) | attendant | `web2 serve` | OpenAPI and MCP, as one command-line tendril, `wrepl` | -Listing here is editorial, reserved for exemplars the maintainers keep. Your hive works the moment it handshakes a humd, whether or not it lives in this repo. +Listing here is editorial, reserved for exemplars the maintainers keep. Your hive works once a humd has it installed and its bee nestles there, whether or not it lives in this repo. [`hum-nest`](../hum-nest) is not a hive: it is the library a Rust nurse hosts its cells with (`serve_nurse`), and the MCP its cells reach tools through. ## Propensity, the three axes of a bee -Every bee sits somewhere on three orthogonal axes, declared in its hello's `propensity`. They decide which `chi` it can keep. +Every bee sits somewhere on three orthogonal axes, declared in its hello's `propensity`. They describe which `chi` it can keep. humd carries them in the bee's manifest and gossips them with it, and routes on none of them. The first axis is **statefulness**. A `stateful` bee holds its own session graph. A `convention-stateful` bee keeps no server state but speaks a protocol that implies continuation. A `stateless` bee treats each call as a fresh sid. A `transport-only` bee is pure bytes in and bytes out. The second axis is **richness**. A `rich` bee hands humd cwd, MCP configs, and the blooms before its prompt as its `graft`. A `medium` bee hands tools, system, and content. A `lean` bee hands only content and system. An `opaque` bee passes JSON straight through. -The third axis is **wire**, the outside contract the bee translates to, for example `openai/chat`, `twilio/sms`, `x402/tendril`, or `grpc/bidi`. +The third axis is **wire**, the outside contract the bee translates to, for example `openai/api`, `twilio/sms-webhook`, `x402/tendril`, or `grpc/bidi`. + +The values are conventions, not a closed set: `serve_nurse` declares its statefulness as `stateful_session`, `stateless_per_call` or `ephemeral_per_call`, and its hive's name as its wire. A forager picks a contract that already exists in the wild and hides the rest of thrum behind it. A lean bee drops the tones it cannot express, while a rich one forwards everything. @@ -50,9 +53,9 @@ A forager picks a contract that already exists in the wild and hides the rest of ### 0. Be a hive -Put an `Orchfile` at the root of your hive's directory. That is what makes it a hive, and the directory's name is the hive's name. hum recognises one unit in it, `hive-install`, and nothing else. `hum hive install` records the hive on the humd and then runs that unit in the foreground, with `HUM_SRC` set to your directory, `HUM_HIVE` to your hive's name, `HUM_HIVE_STATE` to your hive's state directory, and `hum` on its `PATH`; if the unit fails, the hive is not installed. The installed Orchfile carries the same three as `ARG`s, so a unit can say `${HUM_HIVE_STATE}`. +Put an `Orchfile` at the root of your hive's directory. That is what makes it a hive, and the directory's name is the hive's name, a plain name of letters, digits, `-`, `_` and `.` that does not start with `.`. hum recognises one unit in it, `hive-install`, and nothing else. `hum hive install` records the hive on the humd and then runs that unit's `RUN` with `sh -c` in the foreground, in the unit's `WORKDIR` or else your directory, with the unit's `ENV`, `HUM_SRC` set to your directory, `HUM_HIVE` to your hive's name, `HUM_HIVE_STATE` to your hive's state directory, and `hum` on its `PATH`; if the unit fails, the hive is not installed. An `ARG` your Orchfile declares takes its value from the environment of `hum hive install`, which the unit inherits. The copy hum records carries `HUM_SRC`, `HUM_HIVE` and `HUM_HIVE_STATE` as `ARG`s. A supervisor that reads your own Orchfile sees an `ARG` only as you pass it: the bundled `install` passes orchd every `ARG` the Orchfile declares that is set in its environment, which is how a unit there can say `${HUM_HIVE_STATE}`. -What the unit does is yours: build, fetch a model, start processes and keep them alive with whatever supervisor you like. The bundled hives use one small `install` script each that builds and runs their process under [orchd](https://github.com/adiled/orchd) in their own namespace; the process enters its own bee when it starts. Every other unit in the Orchfile is only what your own tooling makes of it. +What the unit does is yours: build, fetch a model, start processes and keep them alive with whatever supervisor you like. The bundled hives that run a process use one small `install` script each, which runs the hive's `build` and then grows the hive's units under [orchd](https://github.com/adiled/orchd), in the namespace `hum-` with its state in `${HUM_HIVE_STATE}/orch`: the unit named after the hive, or every unit for a hive with several (dwarfstar, one per model; ollama-server, its forager and its nurse). The process enters its own bee when it starts. A unit's settings are empty `ARG`s passed on as its `ENV`, as in `bp7/Orchfile`, so `BP7_ROUTE=router.local:4556 hum hive bp7 install` sets one, and an empty one keeps the process's own default. vercel-ai, whose bee lives in the app that imports it, only builds. Every other unit in the Orchfile is only what your own tooling makes of it. ``` ARG HUM_SRC= @@ -66,105 +69,111 @@ RUN ${HUM_SRC}/target/release/my-bee RESTART always ``` -A hive whose bees live elsewhere (a phone, another machine) can have an Orchfile with no units at all; installing it still teaches the humd the kind, and its bees are entered on the humd they land on. +A hive whose bees live elsewhere (a phone, another machine) can have an Orchfile with no units at all; installing it still teaches the humd the kind. Its bees are entered on the humd they land on, and each key goes to wherever its bee runs, as `wifi-p2p`'s goes to the phone. -A hive keeps its state in `$XDG_STATE_HOME/hum/hives//` (`${HUM_HIVE_STATE}`) and its config in `$XDG_CONFIG_HOME/hum/hives/.json`; anything else it writes under a hum root is moved to that root's `tmp/` while humd runs. +A hive keeps its state in `$XDG_STATE_HOME/hum/hives//` (`${HUM_HIVE_STATE}`) and its config in `$XDG_CONFIG_HOME/hum/hives/.json`. Anything else it writes under a hum root is moved to that root's `tmp/` while humd runs (in the config root, once it has sat untouched for ten minutes), and on to `~/.hum-archive/` after a week there. ### 1. Speak thrum -Use hum's client; a hive writes only what its bee does. In Rust that is `hum_thrum::Bee::link`, or `hum_nest::serve_nurse`, which runs a nurse on it, so you implement one trait method and ship a `main.rs`. In TypeScript, Python, Go and C it is the client in [`thrum-clients/`](../thrum-clients), which runs `hum bee link` for you. Either way the client enters the bee, signs its hello, reads humd's verdict, reconnects when humd restarts, and checks every tone against the thrum, the same in every language ([THRUM.md, The client](../THRUM.md#the-client)). +Use hum's client; a hive writes only what its bee does. In Rust that is `hum_thrum::Bee::link`, or `hum_nest::serve_nurse`, which runs a nurse on it, so you implement `Nurse` (`raise`, which turns an `Egg` into a `Cell`, and `ephemeral`) and ship a `main.rs`. A Rust hive is a crate of its own, with its own `[workspace]`, that takes hum's crates as dependencies; humd never builds it. In TypeScript, Python, Go and C it is the client in [`thrum-clients/`](../thrum-clients), which runs `hum bee link` for you and finds `hum` at `$HUM_CLI`, else on `PATH`, else at `~/.local/bin/hum`. Either way the client enters the bee, signs its hello, reads humd's verdict, reconnects when humd restarts, and checks every tone against the thrum, the same in every language ([THRUM.md, The client](../THRUM.md#the-client)). A refusal is final: the client stops, and the bee then exits non-zero, so that whatever supervises it decides what comes next. A bee that cannot run hum's client, such as `wifi-p2p` on a phone, speaks the wire itself ([THRUM.md, Speaking the wire yourself](../THRUM.md#speaking-the-wire-yourself)). ### 2. Handshake, the hello contract -Give the client your role and your hello's own fields. It adds `hive`, `role`, `protoVersion` and the signature itself. +Give the client your role and your hello's own fields. It adds `chi`, `rid`, `hive`, `role`, `protoVersion` and the signature itself, and ignores any value you give for them with the `client-owned` advisory. | field | meaning | |---|---| | `hive` | the hive you belong to, for example `"openai-server"`; it must be installed on the humd you land on; the client sets it | | `role` | the one job you do: `forager`, `nurse`, `builder` or `attendant`, the one your hive entered you for; the client sets it | -| `hid`, `pubkey`, `signedAt`, `signature` | who you are, proven: the id your hive entered you as, and a signature by its key (below); the client sets them | -| `models` | model ids a nurse serves | -| `tools` | tool defs an attendant serves, each `{name, description, inputSchema}` | -| `builds` | a forager's blooms build where it is: a prompt of its with no `cwd` builds at its humd's home | +| `hid`, `from`, `pubkey`, `signedAt`, `signature` | who you are, proven: the id your hive entered you as, and a signature by its key (below); the client sets them | +| `models` | a nurse: the model ids it grows; an attendant: the models it distils a `scent` by | +| `tools` | an attendant: the tool defs it serves, each `{name, description, inputSchema}`; humd ignores them from any other role | +| `builds` | a forager: its blooms build where it is, so a prompt of its with no `cwd` builds at its humd's home | +| `headroom` | a nurse: `{freeSlots, totalSlots}`, how many more cells it can take and how many it holds at most | +| `propensity` | `{statefulness, richness, wire}` (above) | +| `chis` | the chi values you speak; humd sends a bee that lists them only those among the tones it fans out to a sigil, and a bee that lists none every one | | `version`, `protoVersion` | your release, and the `THRUM_VERSION` the client speaks, which it sets | -| `chis` | the chi values you speak; advisory | | `source` | URL to your hive's repo; informational, for humans only | +| `bind` | an address the bee may be reached at, `{host, port, scheme}`, carried in its manifest | > ### A bee signs its hello with the key its hive entered > -> A bee is entered with `hum bee enter --hive --role --as