agentirc-cli 9.4.1__tar.gz → 9.5.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.
Files changed (117) hide show
  1. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/CHANGELOG.md +76 -0
  2. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/PKG-INFO +1 -1
  3. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/bots/http_listener.py +9 -1
  4. agentirc_cli-9.5.0/agentirc/_internal/event_subscriptions.py +282 -0
  5. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/audit.py +7 -0
  6. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/virtual_client.py +36 -16
  7. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/channel.py +31 -4
  8. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/cli.py +4 -0
  9. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/client.py +177 -16
  10. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/config.py +10 -2
  11. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/ircd.py +80 -44
  12. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/protocol.py +132 -2
  13. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/server_link.py +59 -9
  14. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skill.py +10 -37
  15. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/api-stability.md +75 -0
  16. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/cli.md +1 -1
  17. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/deployment.md +9 -1
  18. agentirc_cli-9.5.0/docs/extension-api.md +243 -0
  19. agentirc_cli-9.5.0/docs/superpowers/specs/2026-05-01-bot-extension-api-design.md +458 -0
  20. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/pyproject.toml +1 -1
  21. agentirc_cli-9.5.0/tests/test_bot_capability.py +204 -0
  22. agentirc_cli-9.5.0/tests/test_event_subscriptions.py +335 -0
  23. agentirc_cli-9.5.0/tests/test_eventpub.py +194 -0
  24. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_basic.py +9 -2
  25. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_lifecycle.py +16 -9
  26. agentirc_cli-9.5.0/tests/test_protocol_bot_exports.py +214 -0
  27. agentirc_cli-9.5.0/tests/test_wire_format_envelope.py +325 -0
  28. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/uv.lock +1 -1
  29. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/SKILL.md +0 -0
  30. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
  31. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
  32. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
  33. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
  34. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
  35. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
  36. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
  37. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.claude/skills.local.yaml.example +0 -0
  38. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.github/workflows/publish.yml +0 -0
  39. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.github/workflows/tests.yml +0 -0
  40. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/.gitignore +0 -0
  41. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/CLAUDE.md +0 -0
  42. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/LICENSE +0 -0
  43. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/README.md +0 -0
  44. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/__init__.py +0 -0
  45. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/__main__.py +0 -0
  46. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/__init__.py +0 -0
  47. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/aio.py +0 -0
  48. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/bots/__init__.py +0 -0
  49. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/bots/bot_manager.py +0 -0
  50. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/__init__.py +0 -0
  51. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/constants.py +0 -0
  52. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/mesh.py +0 -0
  53. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/constants.py +0 -0
  54. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/pidfile.py +0 -0
  55. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/__init__.py +0 -0
  56. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/message.py +0 -0
  57. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/replies.py +0 -0
  58. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/__init__.py +0 -0
  59. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/context.py +0 -0
  60. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/metrics.py +0 -0
  61. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/tracing.py +0 -0
  62. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/events.py +0 -0
  63. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/history_store.py +0 -0
  64. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/remote_client.py +0 -0
  65. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/room_store.py +0 -0
  66. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/rooms_util.py +0 -0
  67. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skills/__init__.py +0 -0
  68. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skills/history.py +0 -0
  69. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skills/icon.py +0 -0
  70. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skills/rooms.py +0 -0
  71. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/skills/threads.py +0 -0
  72. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/agentirc/thread_store.py +0 -0
  73. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/steward/onboarding.md +0 -0
  74. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +0 -0
  75. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/docs/superpowers/specs/2026-05-01-task14-audit.md +0 -0
  76. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/__init__.py +0 -0
  77. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/_helpers.py +0 -0
  78. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/conftest.py +0 -0
  79. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/__init__.py +0 -0
  80. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/_fakes.py +0 -0
  81. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/_metrics_helpers.py +0 -0
  82. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_emit.py +0 -0
  83. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_lifecycle.py +0 -0
  84. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_module.py +0 -0
  85. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_parse_error.py +0 -0
  86. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_config.py +0 -0
  87. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_dispatch_span.py +0 -0
  88. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_emit_event_span.py +0 -0
  89. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_metrics_init.py +0 -0
  90. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_metrics_s2s.py +0 -0
  91. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_outbound_inject.py +0 -0
  92. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_parse_error.py +0 -0
  93. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_s2s_relay_span.py +0 -0
  94. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_server_init.py +0 -0
  95. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_server_link_inject.py +0 -0
  96. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/telemetry/test_tracing.py +0 -0
  97. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_channel.py +0 -0
  98. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_cli.py +0 -0
  99. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_config_loader.py +0 -0
  100. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_connection.py +0 -0
  101. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_discovery.py +0 -0
  102. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_catalog.py +0 -0
  103. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_federation.py +0 -0
  104. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_history.py +0 -0
  105. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_events_reserved_nick.py +0 -0
  106. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_federation.py +0 -0
  107. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_history.py +0 -0
  108. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_link_reconnect.py +0 -0
  109. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_mentions.py +0 -0
  110. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_messaging.py +0 -0
  111. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_modes.py +0 -0
  112. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_room_persistence.py +0 -0
  113. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_rooms_federation.py +0 -0
  114. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_rooms_integration.py +0 -0
  115. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_server_icon_skill.py +0 -0
  116. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_skills.py +0 -0
  117. {agentirc_cli-9.4.1 → agentirc_cli-9.5.0}/tests/test_threads.py +0 -0
@@ -4,6 +4,82 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [9.5.0] - 2026-05-02
8
+
9
+ Closes [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15) — out-of-process bot extension API. Unblocks [agentculture/culture#308](https://github.com/agentculture/culture/issues/308) Phase A2 (bot rewrite against the public API).
10
+
11
+ ### Added
12
+
13
+ - **IRCv3 `agentirc.io/bot` capability.** Advertised in `CAP LS` output. When negotiated via `CAP REQ`, gates four behaviours: silent JOIN/PART/QUIT broadcasts (other channel members see nothing), no auto-op on a fresh-channel first-joiner, `+` prefix in NAMES output, `B` flag in WHO output. Channel membership is added normally; events still fire (subscribers see them via `EVENTSUB`).
14
+ - **`EVENTSUB` / `EVENTUNSUB` / `EVENT` / `EVENTERR` IRC verbs** for streaming events to bots out-of-process. `EVENTSUB <sub-id> [type=<glob>] [channel=<name>] [nick=<glob>]`: filters AND-ed; `type` and `nick` accept `fnmatch`-style globs; `channel` accepts an exact name, `*`, or empty (nick-scoped only). Multiple concurrent subscriptions per client are allowed. Wire format: `:server EVENT <sub-id> <type> <channel-or-*> <nick> :<base64-json-envelope>` carrying the canonical 5-field envelope. Per-subscription bounded queue (default 1024); on overflow the server emits `EVENTERR <sub-id> :backpressure-overflow` and drops the subscription (connection stays open). Subscriptions die on client disconnect.
15
+ - **`EVENTPUB` IRC verb** for bots to emit custom-typed events back into the stream. `EVENTPUB <type> <channel-or-*> :<base64-json-data>`. Type validated against `EVENT_TYPE_RE` (dotted lowercase, ≥1 dot — single-segment names like `message` and `topic` are reserved for built-in vocabulary). Server fills `nick` from the bot's connection nick (not spoofable) and `timestamp` from `time.time()` so federation peers see consistent clocks. `_`-prefixed keys are stripped from the payload before emit.
16
+ - New internal module `agentirc._internal.event_subscriptions` with the `Subscription` dataclass and `SubscriptionRegistry`. `IRCd.subscription_registry` exposes the registry; `IRCd.emit_event` dispatches every event through it.
17
+ - `agentirc.protocol.SEVENT` verb constant (added in 9.5.0a2; reaffirmed here).
18
+
19
+ ### Changed
20
+
21
+ - **`webhook_port` is no longer bound by `IRCd.start()`.** The field stays in `ServerConfig` so culture's `~/.culture/server.yaml` keeps loading unchanged, but `agentirc` no longer instantiates the HTTP listener. Consumers that need webhook→bot dispatch host their own listener (see `docs/deployment.md`). No deprecation warning at runtime — the docs change is sufficient.
22
+ - `Channel.add()` no longer auto-ops bot-CAP clients (real or `VirtualClient`). A bot joining an empty channel stays unprivileged; the next human becomes op.
23
+ - `Channel.get_prefix()` returns `+` for bot-CAP members in NAMES output.
24
+ - `Client._build_who_flags()` adds the `B` flag for bot-CAP members in WHO output (composes with `H` and `@`/`+`).
25
+ - `VirtualClient` gains a class-level `caps = frozenset({"agentirc.io/bot", "message-tags"})` so the in-process system bot is treated identically to a real CAP-bot.
26
+ - `Client._handle_cap` `CAP LS` reply now lists supported caps from a class-level `_SUPPORTED_CAPS` frozenset (centralised; removing a cap is a major bump).
27
+ - `agentirc/_internal/bots/http_listener.py` module docstring notes the no-op stub is scheduled for removal in 9.6.0.
28
+
29
+ ### Notes
30
+
31
+ - This is the **final slice** of the bot extension API. `9.5.0a1` shipped the public `agentirc.protocol` declarations; `9.5.0a2` switched the federation wire format to the 5-field envelope; this release wires the actual behaviour.
32
+ - **Federation interop unchanged from 9.5.0a2.** 9.4→9.5 federation works (sniff tolerance); 9.5→9.4 emit breaks until peers upgrade.
33
+ - The `agentirc.skill.{Event, EventType}` re-export shim from 9.5.0a1 stays through the 9.x line; removal is scheduled for 10.0.0.
34
+ - The `agentirc/_internal/bots/` synthesize stubs (`bot_manager.py`, `http_listener.py`) stay through the 9.5.x cycle; removal is scheduled for 9.6.0 once Phase A2 confirms no consumer imports them.
35
+
36
+ ## [9.5.0a2] - 2026-05-02
37
+
38
+ ### Changed
39
+
40
+ - **Wire format: SEVENT payload and IRCv3 `event-data` tag now carry the 5-field envelope** `{type, channel, nick, data, timestamp}`. Both the federation seam (`SEVENT` between linked servers) and the human-visible `#system` PRIVMSG `event-data` tag emit the new shape via `IRCd._build_event_envelope` + `IRCd._encode_event_data`. Type-specific keys (e.g. `text`, `room_id`, `purpose`) now nest under `data`; `nick` and `channel` remain at the top level. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` § Decision A for the rationale.
41
+ - `IRCd._build_event_payload` is replaced by `IRCd._build_event_envelope` (internal — no public API impact). Old name removed; the only two callsites (`_surface_event_privmsg` and `ServerLink.relay_event`'s SEVENT fallback) updated in the same commit.
42
+
43
+ ### Added
44
+
45
+ - `ServerLink._is_envelope(decoded)` — sniff helper that recognises the new envelope shape vs. the legacy data-only dict at decode time. `_handle_sevent` uses it for asymmetric tolerance (see Notes).
46
+ - `agentirc.protocol.SEVENT` — verb-name constant for the federation event-relay verb. Used by the new test suite and the outbound relay path. Other agentirc-side string-literal callsites are tracked separately in [#11](https://github.com/agentculture/agentirc/issues/11).
47
+ - `tests/test_wire_format_envelope.py` — 12 tests including a golden-file byte-equality lock-in for the canonical JSON encoding (any future change to the encoder that breaks this is a wire-format break and requires a major bump), plus regression guards for the trust-bypass and underscore-injection fixes below.
48
+
49
+ ### Security
50
+
51
+ - **`ServerLink._handle_sevent` now treats the SEVENT verb-arg channel as authoritative.** The previous draft of this PR (pre-review) used the envelope's `channel` field on the receive side, which would have allowed a malformed/malicious peer to bypass `_check_incoming_trust` by sending `target="*"` (no trust check) while putting a restricted channel name in the envelope. The verb-arg channel is what the trust gate already protects, so the receiver now ignores any envelope channel claim and uses `verb_channel` for both the trust check and the resulting `Event.channel`.
52
+ - **Peer-supplied `_`-prefixed keys are stripped from incoming SEVENT data before emit.** `_render` and similar server-internal hints would otherwise let a peer dictate human-readable surfacing on this server. The receiver strips every key starting with `_` from the decoded data before adding its own `_origin` marker.
53
+
54
+ ### Fixed
55
+
56
+ - **`IRCd._encode_event_data` fallback now produces a valid 5-field envelope instead of `b"{}"`.** A serialization failure used to emit a bare empty dict, which fails the receiver's envelope sniff and is misclassified as legacy data-only — worse than emitting an event with no type-specific payload. The fallback now emits `{type, channel, nick, data: {}, timestamp: time.time()}` so consumers can still parse the shape.
57
+
58
+ ### Notes
59
+
60
+ - **Federation interop story.** A 9.5 daemon's `_handle_sevent` sniffs the decoded payload: if it has a top-level `type` (string) and `data` (dict), treat as 9.5+ envelope; otherwise treat as ≤9.4 legacy data-only and reconstruct an `Event` from the SEVENT verb args + the bare data dict. This gives 9.5 receivers asymmetric tolerance — they read traffic from both upgraded and pre-9.5 peers — so federations can roll forward one peer at a time. The reverse direction (9.5 emit → 9.4 receive) does **not** work; 9.4 daemons have no sniff and will fail to find expected `data` fields. Operators must either upgrade all peers in lockstep, or roll one at a time and accept that 9.5-emitted events drop on still-9.4 peers until those peers also upgrade. This matches the migration cost the spec calls out.
61
+ - **Audit JSONL is unchanged.** `agentirc/_internal/telemetry/audit.py:build_audit_record` continues to record the legacy data-only payload (data dict with `nick`/`channel` merged in via `setdefault`, `_`-prefixed keys stripped). Ops dashboards and downstream log processors that read the audit JSONL keep their existing field shape; the 5-field envelope is reserved for the public network surface (SEVENT payload + IRCv3 `event-data` tag).
62
+ - **Audit timestamp on legacy peers.** When `_handle_sevent` decodes a legacy payload from a ≤9.4 peer, no originating timestamp is available; the receiver assigns `time.time()` to the reconstructed `Event.timestamp`. 9.5+ peers preserve the originating timestamp.
63
+ - This is the **wire-format slice** of the bot extension API. The next slice (9.5.0a3) wires the bot CAP behavior (`agentirc.io/bot`), the `EVENTSUB`/`EVENTUNSUB`/`EVENTPUB` handlers, and the `SubscriptionRegistry`. 9.5.0 final adds `webhook_port` unbinding and flips `docs/api-stability.md` to "current".
64
+ - Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
65
+
66
+ ## [9.5.0a1] - 2026-05-02
67
+
68
+ ### Added
69
+
70
+ - Public `agentirc.protocol` exports for the bot extension API: the `Event` dataclass, the `EventType` enum, 20 per-type `EVENT_TYPE_*` string constants, the `EVENTSUB` / `EVENTUNSUB` / `EVENT` / `EVENTERR` / `EVENTPUB` verb constants, and the `BOT_CAP = "agentirc.io/bot"` capability identifier. `Event.type` is widened to `EventType | str` so federation peers can deliver event types this version doesn't recognise. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` for the full design and `docs/extension-api.md` for the bot-author quick reference.
71
+ - `ServerConfig.event_subscription_queue_max: int = 1024` — per-subscription queue bound. Recognised by `ServerConfig.from_yaml` and `cli._resolve_config()` as a top-level YAML key. Consumed by the subscription registry that lands in 9.5.0a3.
72
+ - `agentirc.skill` keeps re-exporting `Event` and `EventType` for backward compat; the re-export shim is removed in 9.6.0 once Phase A2 confirms no consumer relies on the path.
73
+
74
+ ### Changed
75
+
76
+ - `EventType` upgraded from `enum.Enum` to `enum.StrEnum`. Members keep their `.value` strings unchanged, but Python consumers now observe new identity semantics: `isinstance(EventType.JOIN, str)` is `True`, `EventType.JOIN == "user.join"` is `True`, JSON serialization emits the bare string. Internal call sites verified — none compare `EventType.X` against a bare string today, so the truthier equality is pure improvement; downstream consumers that did string-equality round-trips against `EventType` see strictly more matches, never fewer.
77
+
78
+ ### Notes
79
+
80
+ - This is the **declarations slice** of the bot extension API. No daemon-level behavior changes: `EVENTSUB` etc. are reserved verb constants but the daemon does not yet handle them; `BOT_CAP` is exported but not yet advertised in `CAP LS` output. The wire-format envelope refactor lands in 9.5.0a2; the bot CAP behavior, subscription verbs, `EVENTPUB`, and `webhook_port` unbinding land in 9.5.0a3 / 9.5.0 final.
81
+ - Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
82
+
7
83
  ## [9.4.1] - 2026-05-01
8
84
 
9
85
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentirc-cli
3
- Version: 9.4.1
3
+ Version: 9.5.0
4
4
  Summary: Agent-friendly IRCd: server core for AI agent meshes
5
5
  Project-URL: Homepage, https://github.com/agentculture/agentirc
6
6
  Project-URL: Issues, https://github.com/agentculture/agentirc/issues
@@ -1,10 +1,18 @@
1
- """No-op ``HttpListener`` stub.
1
+ """No-op ``HttpListener`` stub (scheduled for removal in 9.6.0).
2
2
 
3
3
  Pairs with the no-op :class:`agentirc._internal.bots.bot_manager.BotManager`.
4
4
  The real implementation lives in ``culture.bots.http_listener`` and exposes
5
5
  a webhook surface for triggering bot events. In a standalone agentirc
6
6
  deployment there is nothing to listen for, so ``start()`` and ``stop()``
7
7
  are no-ops.
8
+
9
+ As of 9.5.0, :class:`agentirc.ircd.IRCd` no longer instantiates this stub —
10
+ the webhook listener is the consumer's responsibility (see
11
+ ``docs/api-stability.md`` and ``docs/deployment.md``). The class itself
12
+ stays in the codebase for one cycle so any vendored test or culture-runtime
13
+ override that imports it keeps working; it is scheduled for deletion in
14
+ 9.6.0 once Phase A2 of agentculture/culture#308 confirms no consumer
15
+ imports it.
8
16
  """
9
17
 
10
18
  from __future__ import annotations
@@ -0,0 +1,282 @@
1
+ """Per-client event subscription registry for the bot extension API (9.5.0).
2
+
3
+ Public-facing wire format and verb syntax are described in the design spec at
4
+ ``docs/superpowers/specs/2026-05-01-bot-extension-api-design.md`` § Decision B.
5
+ This module is internal — consumers interact via the ``EVENTSUB``/``EVENTUNSUB``
6
+ IRC verbs handled by :class:`agentirc.client.Client`.
7
+
8
+ Design points:
9
+
10
+ - One :class:`Subscription` per ``sub-id`` per client. Multiple concurrent
11
+ subscriptions per client are allowed; each gets its own bounded queue and
12
+ its own drain task.
13
+ - Filter fields are AND-ed; ``type`` and ``nick`` accept ``fnmatch``-style
14
+ globs (``*``/``?``/``[]``); ``channel`` is exact match (or ``"*"`` for any
15
+ channel, or ``""`` for nick-scoped events only).
16
+ - Backpressure: a per-subscription ``asyncio.Queue`` bounded by
17
+ ``ServerConfig.event_subscription_queue_max``. On overflow, the registry
18
+ sends ``EVENTERR <sub-id> :backpressure-overflow`` and removes the
19
+ subscription. The connection itself stays open — the bot can re-subscribe
20
+ with the same or a fresh ``sub-id`` and use ``BACKFILL`` to recover.
21
+ - The drain task encodes events via :func:`agentirc.ircd.IRCd._build_event_envelope`
22
+ + :func:`agentirc.ircd.IRCd._encode_event_data`, so the ``EVENT`` line on the
23
+ wire carries the same canonical 5-field envelope as the federated ``SEVENT``
24
+ payload and the IRCv3 ``event-data`` tag.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import fnmatch
31
+ import logging
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from typing import TYPE_CHECKING, Any, Callable
35
+
36
+ if TYPE_CHECKING:
37
+ from agentirc.client import Client
38
+ from agentirc.protocol import Event
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # A per-subscription channel filter that matches every channel, including the
44
+ # nick-scoped ``None`` case.
45
+ CHANNEL_ANY = "*"
46
+ # A per-subscription channel filter that matches only nick-scoped events
47
+ # (events with ``event.channel is None``).
48
+ CHANNEL_NICK_SCOPED_ONLY = ""
49
+
50
+ # Channel-filter format for ``EVENTSUB``: an exact channel name (``#``-prefixed),
51
+ # the literal ``*`` (any channel including nick-scoped), or the empty string
52
+ # (nick-scoped events only). Anything else is rejected so subscribers don't
53
+ # silently bind a filter that never matches.
54
+ _CHANNEL_FILTER_RE = re.compile(r"^(#[^\s,]+|\*|)$")
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Filter-parameter dispatch table for EVENTSUB
59
+ # ---------------------------------------------------------------------------
60
+ # Each handler receives the partially-built ``filters`` dict and the value
61
+ # from one ``key=value`` token; it mutates ``filters`` and returns either
62
+ # ``None`` on success or an error-reason string suitable for
63
+ # ``EVENTERR <sub-id> :<reason>``. ``Client._parse_eventsub_filters`` walks
64
+ # the tokens and dispatches via :data:`FILTER_HANDLERS`.
65
+
66
+
67
+ def _set_type_glob(filters: dict, value: str) -> str | None:
68
+ filters["type_glob"] = value or "*"
69
+ return None
70
+
71
+
72
+ def _set_channel(filters: dict, value: str) -> str | None:
73
+ if not _CHANNEL_FILTER_RE.match(value):
74
+ return f"invalid-channel-filter {value}"
75
+ filters["channel"] = CHANNEL_NICK_SCOPED_ONLY if value == "" else value
76
+ return None
77
+
78
+
79
+ def _set_nick_glob(filters: dict, value: str) -> str | None:
80
+ filters["nick_glob"] = value or "*"
81
+ return None
82
+
83
+
84
+ FILTER_HANDLERS: dict[str, Callable[[dict, str], "str | None"]] = {
85
+ "type": _set_type_glob,
86
+ "channel": _set_channel,
87
+ "nick": _set_nick_glob,
88
+ }
89
+
90
+
91
+ @dataclass
92
+ class Subscription:
93
+ """A single subscription owned by a client.
94
+
95
+ Constructed by :meth:`SubscriptionRegistry.add`; do not instantiate
96
+ directly. ``queue`` and ``drain_task`` are populated by the registry.
97
+ """
98
+
99
+ sub_id: str
100
+ type_glob: str = "*"
101
+ channel: str = CHANNEL_ANY
102
+ nick_glob: str = "*"
103
+ queue: asyncio.Queue = field(default_factory=asyncio.Queue)
104
+ drain_task: asyncio.Task | None = None
105
+ dropped: bool = False
106
+
107
+ def matches(self, event: Event) -> bool:
108
+ """Return True if *event* satisfies the AND-ed filter fields."""
109
+ type_str = str(event.type)
110
+ if self.type_glob != "*" and not fnmatch.fnmatchcase(type_str, self.type_glob):
111
+ return False
112
+ if self.channel != CHANNEL_ANY:
113
+ if self.channel == CHANNEL_NICK_SCOPED_ONLY:
114
+ if event.channel is not None:
115
+ return False
116
+ else:
117
+ if event.channel != self.channel:
118
+ return False
119
+ if self.nick_glob != "*" and not fnmatch.fnmatchcase(event.nick, self.nick_glob):
120
+ return False
121
+ return True
122
+
123
+
124
+ class SubscriptionRegistry:
125
+ """Routes events to per-client subscription queues.
126
+
127
+ Owned by :class:`agentirc.ircd.IRCd`. ``IRCd.emit_event`` calls
128
+ :meth:`dispatch` on every event; subscriber clients drain their per-sub
129
+ queues via background tasks.
130
+ """
131
+
132
+ def __init__(self, *, queue_max: int = 1024) -> None:
133
+ self._subs: dict[Client, dict[str, Subscription]] = {}
134
+ self._queue_max = queue_max
135
+
136
+ @property
137
+ def queue_max(self) -> int:
138
+ return self._queue_max
139
+
140
+ def get(self, client: Client, sub_id: str) -> Subscription | None:
141
+ return self._subs.get(client, {}).get(sub_id)
142
+
143
+ def list_for_client(self, client: Client) -> list[Subscription]:
144
+ return list(self._subs.get(client, {}).values())
145
+
146
+ def add(
147
+ self,
148
+ client: Client,
149
+ sub_id: str,
150
+ *,
151
+ type_glob: str = "*",
152
+ channel: str = CHANNEL_ANY,
153
+ nick_glob: str = "*",
154
+ ) -> Subscription | None:
155
+ """Register a new subscription. Returns None on sub-id collision.
156
+
157
+ The caller is responsible for sending the appropriate ``EVENTERR``
158
+ when the result is ``None``.
159
+ """
160
+ per_client = self._subs.setdefault(client, {})
161
+ if sub_id in per_client:
162
+ return None
163
+ sub = Subscription(
164
+ sub_id=sub_id,
165
+ type_glob=type_glob,
166
+ channel=channel,
167
+ nick_glob=nick_glob,
168
+ queue=asyncio.Queue(maxsize=self._queue_max),
169
+ )
170
+ per_client[sub_id] = sub
171
+ sub.drain_task = asyncio.create_task(
172
+ self._drain(client, sub),
173
+ name=f"event-sub-drain[{sub_id}]",
174
+ )
175
+ return sub
176
+
177
+ def remove(self, client: Client, sub_id: str) -> bool:
178
+ """Cancel and forget *sub_id* on *client*. Returns True on hit."""
179
+ per_client = self._subs.get(client)
180
+ if not per_client or sub_id not in per_client:
181
+ return False
182
+ sub = per_client.pop(sub_id)
183
+ sub.dropped = True
184
+ if sub.drain_task is not None and not sub.drain_task.done():
185
+ sub.drain_task.cancel()
186
+ if not per_client:
187
+ self._subs.pop(client, None)
188
+ return True
189
+
190
+ def remove_client(self, client: Client) -> None:
191
+ """Cancel every subscription owned by *client*. Called on disconnect."""
192
+ per_client = self._subs.pop(client, {})
193
+ for sub in per_client.values():
194
+ sub.dropped = True
195
+ if sub.drain_task is not None and not sub.drain_task.done():
196
+ sub.drain_task.cancel()
197
+
198
+ async def dispatch(self, event: Event) -> None:
199
+ """Enqueue *event* on every matching subscription.
200
+
201
+ On queue overflow, mark the subscription dropped, send the bot an
202
+ ``EVENTERR <sub-id> :backpressure-overflow`` line, and remove the
203
+ subscription from the registry. The bot's connection stays open.
204
+ """
205
+ # ``dispatch`` may call ``_handle_overflow`` which awaits
206
+ # ``client.send_raw``; while that yields, another coroutine can call
207
+ # ``add`` or ``remove`` and mutate the dicts. The ``list()`` snapshots
208
+ # avoid ``RuntimeError: dictionary changed size during iteration``.
209
+ for client, per_client in list(self._subs.items()): # NOSONAR python:S7504: defensive snapshot vs. concurrent mutation
210
+ for sub in list(per_client.values()): # NOSONAR python:S7504: defensive snapshot vs. concurrent mutation
211
+ if sub.dropped:
212
+ continue
213
+ if not sub.matches(event):
214
+ continue
215
+ try:
216
+ sub.queue.put_nowait(event)
217
+ except asyncio.QueueFull:
218
+ await self._handle_overflow(client, sub)
219
+
220
+ async def _handle_overflow(self, client: Client, sub: Subscription) -> None:
221
+ sub.dropped = True
222
+ try:
223
+ await client.send_raw(f"EVENTERR {sub.sub_id} :backpressure-overflow")
224
+ except Exception:
225
+ logger.exception(
226
+ "Failed to send backpressure-overflow EVENTERR for sub %s",
227
+ sub.sub_id,
228
+ )
229
+ self.remove(client, sub.sub_id)
230
+
231
+ async def _drain(self, client: Client, sub: Subscription) -> None:
232
+ """Pull events off *sub.queue* and send them as ``EVENT`` lines.
233
+
234
+ Imports :mod:`agentirc.ircd` and :mod:`agentirc.protocol` lazily to
235
+ avoid an import cycle (``ircd.py`` instantiates this registry).
236
+ """
237
+ from agentirc.ircd import IRCd
238
+ from agentirc.protocol import EVENT
239
+
240
+ server_name = client.server.config.name
241
+
242
+ # ``CancelledError`` (raised when ``remove``/``remove_client`` cancels
243
+ # the drain task) propagates out of this coroutine naturally — no
244
+ # cleanup is needed because the registry already removed the
245
+ # subscription before cancelling the task.
246
+ while True:
247
+ event = await sub.queue.get()
248
+ if sub.dropped:
249
+ return
250
+
251
+ type_wire = str(event.type)
252
+ target = event.channel if event.channel is not None else "*"
253
+ envelope = IRCd._build_event_envelope(event)
254
+ encoded = IRCd._encode_event_data(envelope, type_wire)
255
+ line = (
256
+ f":{server_name} {EVENT} {sub.sub_id} {type_wire} "
257
+ f"{target} {event.nick} :{encoded}"
258
+ )
259
+ try:
260
+ await client.send_raw(line)
261
+ except Exception:
262
+ # Connection is gone or send failed; let the disconnect
263
+ # cleanup path remove this subscription.
264
+ logger.debug(
265
+ "EVENT send to %s sub %s failed; awaiting disconnect cleanup",
266
+ getattr(client, "nick", "<?>"),
267
+ sub.sub_id,
268
+ )
269
+ return
270
+
271
+
272
+ __all__ = [
273
+ "CHANNEL_ANY",
274
+ "CHANNEL_NICK_SCOPED_ONLY",
275
+ "Subscription",
276
+ "SubscriptionRegistry",
277
+ ]
278
+
279
+
280
+ # Re-export ``Any`` to satisfy strict-import linters that flag the typing import
281
+ # above. (Subscription.queue is parameterized in docstrings only.)
282
+ _ = Any
@@ -313,6 +313,13 @@ def build_audit_record(
313
313
 
314
314
  See culture/protocol/extensions/audit.md for the field set.
315
315
  """
316
+ # Audit JSONL records the legacy data-only payload (data dict with
317
+ # nick/channel merged in via setdefault, _-prefixed keys stripped).
318
+ # This intentionally diverges from the 9.5+ public wire envelope built
319
+ # by IRCd._build_event_envelope: ops dashboards and downstream log
320
+ # processors that read the audit JSONL keep their existing field shape;
321
+ # the 5-field envelope is reserved for the public network surface
322
+ # (SEVENT payload + IRCv3 event-data tag).
316
323
  payload = {k: v for k, v in (event.data or {}).items() if not k.startswith("_")}
317
324
  if event.nick:
318
325
  payload.setdefault("nick", event.nick)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  from typing import TYPE_CHECKING
7
7
 
8
+ from agentirc.protocol import BOT_CAP
8
9
  from agentirc.skill import Event, EventType
9
10
  from agentirc._internal.protocol.message import Message
10
11
 
@@ -27,6 +28,17 @@ class VirtualClient:
27
28
  in channel.members, NAMES, WHO, and WHOIS transparently.
28
29
  """
29
30
 
31
+ # The in-process system bot is treated identically to a real CAP-bot
32
+ # everywhere: external code paths (``Channel._local_members``,
33
+ # ``Channel.get_prefix``, ``Client._build_who_flags``) read this attr
34
+ # via ``getattr(member, "caps", ...)`` and apply the no-auto-op,
35
+ # ``+``-prefix, and ``B``-flag rules; the methods on this class
36
+ # (``join_channel``/``part_channel``) check it themselves to suppress
37
+ # their own JOIN/PART broadcasts to other channel members. Class-level
38
+ # so every instance gets the same caps; VirtualClients don't negotiate
39
+ # caps the way real Clients do.
40
+ caps: frozenset[str] = frozenset({BOT_CAP, "message-tags"})
41
+
30
42
  def __init__(self, nick: str, user: str, server: IRCd):
31
43
  self.nick = nick
32
44
  self.user = user
@@ -63,14 +75,19 @@ class VirtualClient:
63
75
  # Ensure bot is never auto-promoted to operator
64
76
  channel.operators.discard(self)
65
77
 
66
- join_msg = Message(
67
- prefix=self.prefix,
68
- command="JOIN",
69
- params=[channel_name],
70
- )
71
- for member in [*channel.members]:
72
- if member is not self:
73
- await member.send(join_msg)
78
+ # Bot-CAP clients (the entire VirtualClient class) skip the
79
+ # per-member JOIN broadcast — silent presence to other members.
80
+ # Channel membership above is already added; the user.join event
81
+ # below still fires so EVENTSUB subscribers see it.
82
+ if BOT_CAP not in self.caps:
83
+ join_msg = Message(
84
+ prefix=self.prefix,
85
+ command="JOIN",
86
+ params=[channel_name],
87
+ )
88
+ for member in [*channel.members]:
89
+ if member is not self:
90
+ await member.send(join_msg)
74
91
 
75
92
  if emit_event:
76
93
  await self.server.emit_event(
@@ -83,14 +100,17 @@ class VirtualClient:
83
100
  if not channel or self not in channel.members:
84
101
  return
85
102
 
86
- part_msg = Message(
87
- prefix=self.prefix,
88
- command="PART",
89
- params=[channel_name],
90
- )
91
- for member in [*channel.members]:
92
- if member is not self:
93
- await member.send(part_msg)
103
+ # Symmetric with join_channel — bot-CAP clients skip the per-member
104
+ # PART broadcast. The user.part event below still fires.
105
+ if BOT_CAP not in self.caps:
106
+ part_msg = Message(
107
+ prefix=self.prefix,
108
+ command="PART",
109
+ params=[channel_name],
110
+ )
111
+ for member in [*channel.members]:
112
+ if member is not self:
113
+ await member.send(part_msg)
94
114
 
95
115
  await self.server.emit_event(
96
116
  Event(
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Union
4
4
 
5
+ from agentirc.protocol import BOT_CAP
6
+
5
7
  if TYPE_CHECKING:
6
8
  from agentirc.client import Client
7
9
  from agentirc.remote_client import RemoteClient
@@ -40,18 +42,36 @@ class Channel:
40
42
  return self.room_id is not None
41
43
 
42
44
  def _local_members(self) -> set[Client]:
43
- """Return only local (non-remote, non-virtual) members."""
45
+ """Return only local (non-remote, non-virtual, non-bot-CAP) members.
46
+
47
+ Used as the auto-op eligibility predicate by :meth:`add`. Bot-CAP
48
+ clients are excluded so a bot joining an empty channel never becomes
49
+ op — a human joining later does.
50
+ """
44
51
  from agentirc.remote_client import RemoteClient
45
52
  from agentirc._internal.virtual_client import VirtualClient
46
53
 
47
- return {m for m in self.members if not isinstance(m, (RemoteClient, VirtualClient))}
54
+ return {
55
+ m
56
+ for m in self.members
57
+ if not isinstance(m, (RemoteClient, VirtualClient))
58
+ and BOT_CAP not in getattr(m, "caps", frozenset())
59
+ }
48
60
 
49
61
  def add(self, client: Client) -> None:
50
- # Only grant op to the first LOCAL joiner
62
+ # Only grant op to the first LOCAL joiner. Bot-CAP clients
63
+ # (real or VirtualClient) are excluded — they never auto-op,
64
+ # so a bot joining an empty channel stays unprivileged and
65
+ # the next human becomes op.
51
66
  if not self._local_members():
52
67
  from agentirc.remote_client import RemoteClient
68
+ from agentirc._internal.virtual_client import VirtualClient
53
69
 
54
- if not isinstance(client, RemoteClient):
70
+ is_op_eligible = (
71
+ not isinstance(client, (RemoteClient, VirtualClient))
72
+ and BOT_CAP not in getattr(client, "caps", frozenset())
73
+ )
74
+ if is_op_eligible:
55
75
  self.operators.add(client)
56
76
  self.members.add(client)
57
77
 
@@ -77,4 +97,11 @@ class Channel:
77
97
  return "@"
78
98
  if client in self.voiced:
79
99
  return "+"
100
+ # Bot-CAP clients render with the voice prefix in NAMES output —
101
+ # the closest standard IRC mode for "non-disruptive participant",
102
+ # so vanilla IRC clients filter bots from presence panels by
103
+ # checking the ``+`` prefix. Op wins on conflict (above), so an
104
+ # explicitly-opped bot still renders as ``@``.
105
+ if BOT_CAP in getattr(client, "caps", frozenset()):
106
+ return "+"
80
107
  return ""
@@ -185,6 +185,9 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
185
185
  args.data_dir, raw.get("data_dir"), os.path.expanduser("~/.culture/data")
186
186
  )
187
187
  )
188
+ event_subscription_queue_max = _pick(
189
+ None, raw.get("event_subscription_queue_max"), 1024
190
+ )
188
191
 
189
192
  cfg = ServerConfig(
190
193
  name=name or "agentirc",
@@ -195,6 +198,7 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
195
198
  links=_resolve_links(getattr(args, "link", None), raw.get("links") or []),
196
199
  system_bots=raw.get("system_bots") or {},
197
200
  telemetry=_build_telemetry(raw.get("telemetry") or {}),
201
+ event_subscription_queue_max=event_subscription_queue_max,
198
202
  )
199
203
 
200
204
  args.name = name # may be None — handler resolves via default-server file