yet-another-agentic-chat 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yet_another_agentic_chat-0.1.0/.github/workflows/ci.yml +22 -0
- yet_another_agentic_chat-0.1.0/.github/workflows/publish.yml +20 -0
- yet_another_agentic_chat-0.1.0/.gitignore +222 -0
- yet_another_agentic_chat-0.1.0/.python-version +1 -0
- yet_another_agentic_chat-0.1.0/CLAUDE.md +259 -0
- yet_another_agentic_chat-0.1.0/LICENSE +21 -0
- yet_another_agentic_chat-0.1.0/PKG-INFO +275 -0
- yet_another_agentic_chat-0.1.0/README.md +255 -0
- yet_another_agentic_chat-0.1.0/docs/development.md +49 -0
- yet_another_agentic_chat-0.1.0/docs/message-format.md +67 -0
- yet_another_agentic_chat-0.1.0/docs/zmq.md +73 -0
- yet_another_agentic_chat-0.1.0/pyproject.toml +55 -0
- yet_another_agentic_chat-0.1.0/src/yaac/__init__.py +0 -0
- yet_another_agentic_chat-0.1.0/src/yaac/backend.py +494 -0
- yet_another_agentic_chat-0.1.0/src/yaac/frontend.py +358 -0
- yet_another_agentic_chat-0.1.0/src/yaac/hat.py +294 -0
- yet_another_agentic_chat-0.1.0/src/yaac/protocol.py +329 -0
- yet_another_agentic_chat-0.1.0/tests/conftest.py +38 -0
- yet_another_agentic_chat-0.1.0/tests/test_hard_rules.py +207 -0
- yet_another_agentic_chat-0.1.0/tests/test_protocol.py +275 -0
- yet_another_agentic_chat-0.1.0/tests/test_radio.py +320 -0
- yet_another_agentic_chat-0.1.0/uv.lock +683 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The Windows job is load-bearing: nobody on the project has Windows hardware, so this matrix is where the
|
|
2
|
+
# selector-loop and bind-election facts in CLAUDE.md graduate from source-verified to measured.
|
|
3
|
+
name: CI
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
- run: uv sync --frozen
|
|
21
|
+
- run: uv run ruff check .
|
|
22
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Publishing is never automatic: this runs only when a GitHub release is published, which is a manual act of the
|
|
2
|
+
# maintainer. The trust chain is PyPI's trusted publisher for amyodov/yet-another-agentic-chat, which accepts
|
|
3
|
+
# uploads only from this workflow file running in the `pypi` environment -- OIDC, no token stored anywhere.
|
|
4
|
+
name: Publish
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Anything with "-nogit" in its name stays local: scratch notes, one-off scripts,
|
|
221
|
+
# briefs, dumps. Name a file that way instead of adding a line here.
|
|
222
|
+
*-nogit*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
See `README.md` for what YAAC does. This file is about working on it.
|
|
4
|
+
|
|
5
|
+
`README.md` also tracks features — what is in the current version and what is planned. Update its Status section
|
|
6
|
+
whenever you ship or defer something; it is the only record, as there is no separate roadmap or issue tracker.
|
|
7
|
+
|
|
8
|
+
## Vocabulary
|
|
9
|
+
|
|
10
|
+
| Term | Meaning |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| **frontend** | The MCP server: tool definitions, no state. |
|
|
13
|
+
| **backend** | In-process module: ZMQ sockets, inbox, roster cache. |
|
|
14
|
+
| **hat** | Whichever backend won the bind. Confers the job of passing messages along, and nothing else. |
|
|
15
|
+
| **participant** | Any backend on a channel, wearing the hat or not. |
|
|
16
|
+
| **membership** | One (channel, name) a process holds. A process may hold several. |
|
|
17
|
+
| **connection id** | A membership's routing id, returned by `join_channel` and used to address it. |
|
|
18
|
+
| **channel** | A named conversation. Addressing, not isolation. |
|
|
19
|
+
| **name** | A user-chosen name within a channel. Raw UTF-8. |
|
|
20
|
+
| **routing_id** | ULID set as the ZMQ `ROUTING_ID`; `zmq_routing_id` on the wire. |
|
|
21
|
+
| **address** | `{name, zmq_routing_id}`. How participants are named on the wire — never a bare string. |
|
|
22
|
+
|
|
23
|
+
ZMQ's own words stay ZMQ's own, prefixed: `zmq_routing_id`, not a YAAC synonym for it.
|
|
24
|
+
|
|
25
|
+
"frontend" and "backend" are relative to the MCP tool surface, not web layers. "channel" always means a YAAC channel,
|
|
26
|
+
never the Claude Code feature of the same name.
|
|
27
|
+
|
|
28
|
+
## Layout
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
src/yaac/
|
|
32
|
+
frontend.py MCP tool definitions
|
|
33
|
+
backend.py sockets, bind election, receive loops, connection state
|
|
34
|
+
hat.py routing table, whois, roster, bounce
|
|
35
|
+
protocol.py envelope + control messages, serialization
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.14+, `uv`, `ruff`. Dependencies are `pyzmq` and `mcp` — ask before adding another.
|
|
39
|
+
|
|
40
|
+
To keep a file out of git, put `-nogit` in its name (`notes-nogit.md`, `probe-nogit.py`). `.gitignore` matches
|
|
41
|
+
`*-nogit*`, so don't add per-file entries to it.
|
|
42
|
+
|
|
43
|
+
Line length 120, code and prose alike. Use current syntax freely: `match`/`case`, walrus, PEP 758 `except A, B:`.
|
|
44
|
+
Annotations are lazy in 3.14, so no `from __future__ import annotations`.
|
|
45
|
+
|
|
46
|
+
No nested `if`s where a flat shape exists: prefer `match`/`case` or an `if`/`elif`/`else` chain. Use the `else`
|
|
47
|
+
clause of `try`, `for`, and `while` when it says what the code means — `for`/`else` is exactly "the loop found
|
|
48
|
+
nothing".
|
|
49
|
+
|
|
50
|
+
## Verified facts
|
|
51
|
+
|
|
52
|
+
Measured here, not assumed. Re-measure before contradicting.
|
|
53
|
+
|
|
54
|
+
**`ROUTER_NOTIFY` is unusable and fails in a way that looks fine.** It is a libzmq draft option. The constant imports,
|
|
55
|
+
so a check for `hasattr(zmq, "ROUTER_NOTIFY")` passes, but `setsockopt` rejects it with `EINVAL` — released wheels
|
|
56
|
+
bundle libzmq built without drafts (`zmq.has('draft')` is `False`). True anywhere `pip install pyzmq` is used.
|
|
57
|
+
|
|
58
|
+
Replacements, both non-draft:
|
|
59
|
+
- **Arrival**: the participant's DEALER monitor fires `EVENT_CONNECTED`, and the participant re-sends `hello`. Covers
|
|
60
|
+
the first connect too, so whoever picks up the hat next is told who is present without asking.
|
|
61
|
+
- **Departure**: `ROUTER_MANDATORY` raises `EHOSTUNREACH` when sending to a routing id that left. Eviction is lazy,
|
|
62
|
+
detected on the first failed send — which is when we want to bounce anyway.
|
|
63
|
+
|
|
64
|
+
**Claude Code honours `tools/list_changed`, but only if the server advertised `tools.listChanged: true`.** The SDK's
|
|
65
|
+
`run_stdio_async()` hardcodes `NotificationOptions()` with every flag false, so `main` runs the low-level server
|
|
66
|
+
directly to override it. Advertising false and then sending the notification looks like a client bug and isn't —
|
|
67
|
+
check what `initialize` claimed first.
|
|
68
|
+
|
|
69
|
+
So the tool list is dynamic: dormant sessions list `list_channels` and `join_channel`, the other five appear on first
|
|
70
|
+
join and go on last leave.
|
|
71
|
+
|
|
72
|
+
**MCP cannot push into an idle session.** Nothing in the server→client set (`notifications/message`,
|
|
73
|
+
`resources/updated`, `list_changed`, `sampling/createMessage`, `elicitation/create`) reaches the model's context.
|
|
74
|
+
Delivery is pull-only, so the tool descriptions have to carry the reminder to call `check_inbox`.
|
|
75
|
+
|
|
76
|
+
**Binding a busy port fails in ~0.4 ms** with `EADDRINUSE`. That is why every backend can just try.
|
|
77
|
+
|
|
78
|
+
**Windows needs the selector event loop** (found in pyzmq `zmq/asyncio.py`; measured since — the CI Windows job runs
|
|
79
|
+
the full suite). `zmq.asyncio` waits on sockets with `loop.add_reader`, which the default `ProactorEventLoop` lacks;
|
|
80
|
+
pyzmq then raises `RuntimeError` at first socket use unless tornado ≥ 6.1 is importable. `main` therefore passes
|
|
81
|
+
`loop_factory=asyncio.SelectorEventLoop` on win32 — `loop_factory`, not `set_event_loop_policy`, because 3.14
|
|
82
|
+
deprecates the policy API. The reverse constraint holds in tests: `asyncio.create_subprocess_exec` is proactor-only
|
|
83
|
+
on Windows, so `conftest.py` picks the loop per test module via the `pytest_asyncio_loop_factories` hook.
|
|
84
|
+
|
|
85
|
+
**The bind election stays single-winner on Windows** (found in libzmq `tcp_listener.cpp`; exercised by the CI
|
|
86
|
+
Windows job). Plain
|
|
87
|
+
`SO_REUSEADDR` there would let a second bind of an actively-bound port succeed; libzmq uses `SO_EXCLUSIVEADDRUSE`
|
|
88
|
+
on Windows instead, so exactly one ROUTER holds the endpoint on every OS.
|
|
89
|
+
|
|
90
|
+
**`CLAUDE_CODE_SESSION_ID` is visible to both the MCP server and hooks**, and equals the `session_id` a hook gets on
|
|
91
|
+
stdin. Better key than the cwd if v1 ever needs an out-of-process reader to find the right session, since it
|
|
92
|
+
separates two sessions in one directory. Absent on Claude Desktop. Nothing uses it today.
|
|
93
|
+
|
|
94
|
+
**Several memberships per process.** `Backend` holds a dict of `Membership`, each with its own routing id, DEALER,
|
|
95
|
+
roster and inbox, so the hat sees them as unrelated participants and the protocol needed no change. This matters
|
|
96
|
+
for clients running one server per application rather than per conversation, such as Claude Desktop — where it also
|
|
97
|
+
means one conversation can address another's connection, so `check_inbox` requires the id rather than guessing.
|
|
98
|
+
|
|
99
|
+
## Message format
|
|
100
|
+
|
|
101
|
+
The wire format itself is specified in `docs/message-format.md`; this section is the rules for code that touches it.
|
|
102
|
+
Its agreed successor — the envelope system, not yet built — is specified in `docs/zmq.md`.
|
|
103
|
+
|
|
104
|
+
`protocol.dumps` is the only serializer, and it stamps `yaac: PROTOCOL_VERSION` first on every top-level dict. That
|
|
105
|
+
ordering is the point: every message opens with `{"yaac":1`, a magic number a reader can key on without parsing. No
|
|
106
|
+
trailing comma is promised — a message with no other field ends there. `dumps` asserts its own output against
|
|
107
|
+
`MAGIC`, which is written out by hand so the two can be caught drifting apart.
|
|
108
|
+
|
|
109
|
+
Field order is otherwise whatever the `to_wire` constructors build, so keep `body` last in them: it is the only
|
|
110
|
+
unbounded field, and `head -c` on a log should show routing regardless of body size. Equal content gives equal bytes,
|
|
111
|
+
so a message has one identity to hash or sign later. Nothing computes a signature today.
|
|
112
|
+
|
|
113
|
+
`PROTOCOL_VERSION` marks the encoding generation, not the field list. Renaming or adding fields does not bump it;
|
|
114
|
+
replacing JSON with a binary framing would.
|
|
115
|
+
|
|
116
|
+
Received frames go through `protocol.parse`, never `loads` — it rejects a `yaac` field that is not exactly this
|
|
117
|
+
build's version, checking the parsed value rather than the leading bytes, since that is what the format guarantees.
|
|
118
|
+
`type(version) is not int` because `1.0` and `True` both equal `1`. Do not call `json.dumps` anywhere else.
|
|
119
|
+
|
|
120
|
+
`from` and `to` are `Address` objects, not strings: `{name, zmq_routing_id}`, either nullable. A name is unique on a
|
|
121
|
+
channel only while its holder is connected; a routing id identifies one connection and is never reused. Further
|
|
122
|
+
locators can be added as fields without breaking parsers, which is why this is a structure.
|
|
123
|
+
|
|
124
|
+
`Address`, `Destination` and `Envelope` are frozen dataclasses with `to_wire`/`from_wire`. Parse with `from_wire`
|
|
125
|
+
rather than reading dict keys — it rejects a malformed address instead of coercing it.
|
|
126
|
+
|
|
127
|
+
## Hard rules
|
|
128
|
+
|
|
129
|
+
Each has a test.
|
|
130
|
+
|
|
131
|
+
1. **Nothing is written to stdout.** It carries the stdio transport; one `print()` breaks the session with a parse
|
|
132
|
+
error. Log to stderr via `backend.log`.
|
|
133
|
+
2. **Nothing is ever written to disk, and a dormant server opens no socket.** Unread messages, rosters and
|
|
134
|
+
membership all live in memory and die with the process, so there is nothing to clean up after a crash.
|
|
135
|
+
`Backend` is not constructed at all until `join_channel`.
|
|
136
|
+
3. **`send` never blocks.** A full queue or absent peer must raise. Blocking inside an MCP call freezes the session.
|
|
137
|
+
4. **Participant and channel names are never parsed, split, validated, or case-folded.** That is why routing uses a
|
|
138
|
+
separate opaque routing id: `ROUTING_ID` has length and byte constraints that user-chosen names must not inherit.
|
|
139
|
+
5. **The hat never reads a body.** A body that looks like a control message gets delivered, not obeyed.
|
|
140
|
+
6. **`from` and the sender's channel come from the hat's table**, never from the sender. This is what makes
|
|
141
|
+
cross-channel injection impossible rather than merely forbidden.
|
|
142
|
+
|
|
143
|
+
## Tests
|
|
144
|
+
|
|
145
|
+
**Parametrize hard.** Minimum number of test functions, maximum behaviour each. If a new test would differ from an
|
|
146
|
+
existing one only in its inputs, add a `parametrize` case instead.
|
|
147
|
+
|
|
148
|
+
**Compare values.** Asserting that something was called, or is truthy, or is non-empty, tests nothing — a stub
|
|
149
|
+
returning plausible shapes would pass.
|
|
150
|
+
|
|
151
|
+
**No second argument to `assert`.** It suppresses pytest's rewritten diff. Explain in a comment on the line above.
|
|
152
|
+
|
|
153
|
+
In `src/` the rule is the opposite: no rewriting, and `-O` strips asserts, so use them only for invariants that
|
|
154
|
+
cannot fail unless this code is wrong — `dumps` checking its own output against `MAGIC` is the case. Anything a peer
|
|
155
|
+
or a malformed frame can trigger must `raise`.
|
|
156
|
+
|
|
157
|
+
**ASCII names in tests** — `ann`, `bob`, `forum`. Non-ASCII appears only as parametrized data where encoding is what
|
|
158
|
+
is being tested. Docs and examples may use any UTF-8; the README's Cyrillic name shows that names are
|
|
159
|
+
unrestricted. Tool descriptions are mostly English but need not be only English.
|
|
160
|
+
|
|
161
|
+
## Prose
|
|
162
|
+
|
|
163
|
+
These rules cover everything written in words: comments, docstrings, commit messages, docs — and this file.
|
|
164
|
+
|
|
165
|
+
- Say what the mechanism, constraint, or failure mode is, with real names and numbers. Give the *why*; the *what*
|
|
166
|
+
is in the code, and the next reader is as capable as you.
|
|
167
|
+
- Cover intent, not implementation. Text that restates the line below it says nothing and goes stale the first time
|
|
168
|
+
that line changes. If it is clear from the code, do not repeat it.
|
|
169
|
+
- Technical vocabulary is free: "asynchronous queue with exclusive locking" is exactly right. Fancy general words
|
|
170
|
+
are not — "think", not "ponder". No metaphors or slogans; the radio analogy lives in the README, not the source.
|
|
171
|
+
- Write "`ROUTER_MANDATORY` raises `EHOSTUNREACH` for an unknown routing id, so the send fails instead of being
|
|
172
|
+
dropped", not "a stuck queue must fail, not grow silently".
|
|
173
|
+
- Commit messages: the subject names the change, the body gives the reason and what it displaces. Not a list of
|
|
174
|
+
files touched — the diff already shows that.
|
|
175
|
+
|
|
176
|
+
## Design notes
|
|
177
|
+
|
|
178
|
+
- **The hat is put on by getting there first.** Soft state only, no policy, never configured, and it comes off when the
|
|
179
|
+
process exits.
|
|
180
|
+
- **The hat connects its own DEALER to its own ROUTER.** Costs one socket, and keeps a single send path — no "am I
|
|
181
|
+
the hat" branch anywhere.
|
|
182
|
+
- **Probing must not bind.** If `list_channels` bound, a session that only looked would become hat and drop the
|
|
183
|
+
endpoint when the call returned. Its 10 s timeout is the only exit when nothing is listening, because ZMQ queues
|
|
184
|
+
instead of failing. "Nobody on the air" is a normal answer.
|
|
185
|
+
- **Hold, don't bounce, during a changeover.** A message from an unknown routing id is parked and a `whois` sent, so the
|
|
186
|
+
send succeeds late instead of failing. `pending` is bounded on count and age so a silent peer cannot leak memory.
|
|
187
|
+
- **No heartbeat, no TTL.** An idle net generates zero traffic.
|
|
188
|
+
- **TCP, not `ipc`.** An `ipc://` socket file survives `kill -9` and then blocks bind forever, needing a lock file
|
|
189
|
+
and manual cleanup. The kernel frees a TCP port itself, and libzmq sets `SO_REUSEADDR`.
|
|
190
|
+
- **Port 19116** is `0x4AAC`, below the ephemeral range so the kernel won't hand it out as a source port.
|
|
191
|
+
- **`created` is derived from the roster**, not sent by the hat: channels are deleted when empty, so being alone on
|
|
192
|
+
one means you just made it.
|
|
193
|
+
|
|
194
|
+
## Decided, not built
|
|
195
|
+
|
|
196
|
+
Settled in discussion with Alex; build only when told, ask before deviating. The envelope system is formalized in
|
|
197
|
+
`docs/zmq.md` — that file is the spec, this section tracks the decisions and their open edges.
|
|
198
|
+
|
|
199
|
+
- **Privacy is convention, not protection.** Everything runs on one machine under one user account, where any
|
|
200
|
+
session can already read another's transcript from disk, so YAAC cannot add a boundary the OS does not have.
|
|
201
|
+
Identity mechanisms exist to prevent accidents and default misuse by well-behaved participants — never to stop a
|
|
202
|
+
determined session, and they must not claim otherwise.
|
|
203
|
+
- **`join_channel` will return a `peer_uid` + `peer_secret` pair.** The secret is an honor-system convention, not
|
|
204
|
+
cryptography: a participant that did not receive it through the proper flow is not that peer. The backend verifies
|
|
205
|
+
it locally against its own memberships; the hat cannot verify anything, since its state is rebuilt from `hello`
|
|
206
|
+
after every changeover. Presenting the pair on join resumes the same peer after a client restart. Still open:
|
|
207
|
+
whether the secret gates `send` and `peers` or only inbox reads, and whether `peer_uid` becomes a locator inside
|
|
208
|
+
`from`/`to`.
|
|
209
|
+
- **The world channel is `None`, not a name.** Omitted, null, or empty `channel` at the tool boundary all mean it,
|
|
210
|
+
and the description says so. Distinguished structurally so no user-chosen string can clash with it and no
|
|
211
|
+
English-centric default name exists. Costs to pay when building: the destination frame currently uses a null
|
|
212
|
+
channel to mean "don't cross-check", `hello` requires a string channel, and `list_channels` needs a row for it.
|
|
213
|
+
- **The tool boundary validates; the protocol never does.** Hard rule 4 stays absolute for the hat and the wire,
|
|
214
|
+
but the MCP layer refuses a completely empty `name` — empty is what an unexpanded template looks like, not a
|
|
215
|
+
choice. Only completely empty: `" "` is not empty, and trimming would be parsing. Rescope rule 4's wording
|
|
216
|
+
when this is built.
|
|
217
|
+
- **A message becomes an object, not a string**: `payload` (any JSON — the tool description must say it may be
|
|
218
|
+
anything; the readers are agents and will adapt), `tags` (topic), `mentions` (who is called on to react — while
|
|
219
|
+
everyone in the delivery scope still hears it). Delivery scope (`to`) and social addressing (`mentions`) are
|
|
220
|
+
separate things: a whisper stays private-scope, and mentioning someone on the open channel is heard by all, like
|
|
221
|
+
radio. There is no urgency mechanism; being mentioned is the attention signal, and any loudness convention is a
|
|
222
|
+
tag.
|
|
223
|
+
- **`from` and `to` are scope objects** whose fields compose: `{channel}` broadcasts to it, `{peer}` whispers,
|
|
224
|
+
`{channel, peer}` is that peer as a member of that channel, and `to: {}` — no scope at all — addresses whoever
|
|
225
|
+
wears the hat, for technical asks; symmetrically `from: {}` marks infrastructure messages such as bounces
|
|
226
|
+
(today `from: null`). Senders never transmit `from` at all — the hat stamps it from its table (rule 6), so
|
|
227
|
+
`from: {}` is unforgeable by construction, not by validation. Open: the world channel is a null channel on the
|
|
228
|
+
wire, so `{}` and `{channel: null}` must not collapse — either strict absent-vs-null discipline in the
|
|
229
|
+
serializer, or a dedicated field for the hat address. Also open: whether the message structure travels as an
|
|
230
|
+
end-to-end body object the hat never decodes, or as envelope fields the hat copies.
|
|
231
|
+
- **One envelope for all wire traffic, control included.** `hello`, channel listing, `whois`, `roster`, bounces
|
|
232
|
+
and chat all travel as the same mail shape; the control/data split by frame count disappears. Rule 5 restates
|
|
233
|
+
as: the hat interprets exactly the mail addressed to `{}`, and nothing else — delivery versus obedience decided
|
|
234
|
+
by addressing, not frame layout. What the receiving backend does with operator mail (roster to cache, bounce to
|
|
235
|
+
inbox) is backend policy, not a second format. Decide at build time whether this framing unification bumps
|
|
236
|
+
`PROTOCOL_VERSION`.
|
|
237
|
+
- **Docs examples use the classical cast** — Alice, Bob, Carol; the hat-sees-everything caveat is "the hat is Eve
|
|
238
|
+
by construction". One side note keeps a non-ASCII name to show names are unrestricted.
|
|
239
|
+
|
|
240
|
+
## Out of scope
|
|
241
|
+
|
|
242
|
+
Deferred on purpose — ask before adding.
|
|
243
|
+
|
|
244
|
+
- Outbox, SQLite, retries, acks, dedup, store-and-forward
|
|
245
|
+
- Delivery guarantees. v0 may lose messages, as long as it loses them loudly.
|
|
246
|
+
- Multi-host, CURVE auth, namespaced names
|
|
247
|
+
- Direct participant-to-participant connections after discovery
|
|
248
|
+
- Channel UUIDs as anything more than a reported field
|
|
249
|
+
- Presence beyond "who is on this channel now"
|
|
250
|
+
- Threads, reactions, history, shared task lists
|
|
251
|
+
|
|
252
|
+
## Reference
|
|
253
|
+
|
|
254
|
+
ZeroMQ's **Zyre** (RFC 36 / ZRE) solves nearly this problem: `ENTER`/`EXIT`, `JOIN`/`LEAVE`, `WHISPER`, `SHOUT`, over
|
|
255
|
+
DEALER-ROUTER. Worth reading before changing the protocol. Don't take the dependency — we need a subset, and `pyre`
|
|
256
|
+
is LGPLv3 and tracks Zyre 1.0.
|
|
257
|
+
|
|
258
|
+
Its better idea: separate discovery from data, so peers meet at a rendezvous point and then connect directly. That
|
|
259
|
+
removes the relay bottleneck and the confidentiality caveat in the README. Candidate for later.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Myodov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|