Commit: 2b29421
Version: 3.4dev-20260826_085234
Branch: master
Status: merged and pushed to GitHub
Final full suite: 16762/16762 PASS
Fast lane: 6250/6250 PASS
Targeted AI/Wit suite: 597/597 PASS
Secret scan: clean
Live acceptance: real IRC delivery confirmed
MB700 taught Wit how to observe, think, call an AI provider, make a strict reply/no-reply decision, and log the result safely.
But MB700 deliberately stopped before the last step:
Wit could think, but it could not speak.
MB701 closes that gap.
The important part is that this was not implemented by dropping a botPrivmsg() call inside the AI callback and hoping for the best.
Instead, the entire emission path was built as a sequence of independent gates, each tested separately before the next one was allowed to exist.
The result is the first production-capable proactive AI conversation path in Mediabot v3, while remaining disabled by default.
A public channel message now follows this path:
public IRC line
β
+Wit chanset
β
ConversationPolicy
β
ConversationObserver
β
ConversationRequest
β
AI::Client
β
ConversationDecision
β
late emission authorization
β
runtime joined/generation validation
β
master WIT_SEND_ARMED switch
β
ConversationSender
β
sender rate limit
β
final synchronous state revalidation
β
normal Mediabot botPrivmsg transport
β
IRC
Every arrow above represents a point where the reply can still be discarded safely.
MB701 introduced:
Mediabot/AI/ConversationEmission.pm
This module has one job:
decide whether an already-generated AI reply is still allowed to be emitted right now.
It does not access IRC directly.
It does not query the database directly.
It does not call a provider.
It evaluates final state such as:
+Wit still enabled
runtime active
IRC connected
bot currently joined
request generation still current
valid public channel
safe reply text
reply <= 280 characters
reply <= 350 UTF-8 bytes
Possible rejection reasons include:
disabled
runtime_inactive
irc_disconnected
not_joined
stale_generation
invalid_channel
invalid_reply
unsafe_reply
empty_reply
reply_too_long
reply_too_large
There is deliberately no silent truncation.
Unsafe control characters, CTCP-style content, CR/LF/NUL and inappropriate IRC control bytes fail closed.
One of the most important design findings during MB701 was this:
$mediabot->{channels}
is a configured/database channel view.
It is not sufficient proof that the bot is currently joined.
So MB701 added:
Mediabot/AI/ConversationRuntimeState.pm
This keeps an in-memory truth about the current IRC session:
runtime active?
IRC connected?
channel currently joined?
current generation for that channel?
The generation changes whenever membership becomes invalid.
For example:
JOIN -> new valid generation
PART -> generation invalidated
KICK -> generation invalidated
disconnect -> all channel generations invalidated
reconnect -> new membership generations
This solves a subtle race:
message arrives
AI request starts
bot leaves channel
bot rejoins channel
old AI answer returns
Without a generation token, the bot could mistakenly treat the new JOIN as permission to send an answer belonging to the previous channel session.
MB701 rejects it as:
no_emit / stale_generation
AI requests are asynchronous.
That means authorization at request submission time is not enough.
During the provider call, an administrator can disable Wit, the bot can PART, be KICKed, disconnect, reconnect, or shut down.
MB701 therefore captures a request generation and checks mutable state again after the model reply returns.
A dedicated regression test proves:
+Wit ON
β submit AI request
β callback intentionally held
β -Wit
β release callback
β no_emit / disabled
It also proves:
generation=N
β submit AI request
β PART
β JOIN
β generation=N+2
β old callback returns
β no_emit / stale_generation
This is not a timing-based manual test.
The callback is deliberately delayed by the test harness so the race is deterministic.
The next layer added:
Mediabot/AI/ConversationSender.pm
The sender starts:
DISARMED
even if:
+Wit = ON
This creates two separate permissions:
per-channel permission: +Wit
global runtime permission: WIT_SEND_ARMED
Both must be true.
The sender also owns a completely separate output rate limit:
120 seconds / channel
This is intentionally different from the provider cooldown:
90 seconds
So provider request throttling and actual IRC output throttling are independent protections.
Only a successful delivery consumes the sender budget.
Failures such as:
disabled
stale_generation
state_error
unsafe_reply
send_error
send_failed
kill_switch
do not consume it.
The real IRC transport is confined to a single helper:
_wit_send_transport()
That helper uses the normal historical Mediabot path:
Mediabot::Helpers::botPrivmsg(...)
The proactive AI callback itself still contains no direct:
botPrivmsg
botNotice
send_message
do_PRIVMSG
This matters because the normal Mediabot path already carries existing behavior such as sanitation, channel logging, flood handling and other established runtime protections.
The AI subsystem does not bypass them.
WIT_SEND_ARMEDMB701 introduces a new main configuration key:
WIT_SEND_ARMED=0
The sample configuration documents it as OFF.
The runtime reads it as a strict bounded integer:
default = 0
min = 0
max = 1
Semantics:
1 -> sender armed
0 -> sender disarmed
missing -> sender disarmed
invalid -> fail closed
error -> sender disarmed
The arm/disarm transition is intentionally confined to one helper:
_wit_sync_sender_arm()
Tests explicitly reject any second runtime arm path.
The setting is re-read immediately before a send attempt, so a normal configuration reload can revoke the master permission without restarting the bot.
The feature was not switched on in one shot.
+Wit OFFThe daemon was restarted with the new runtime membership code.
Result:
new PID
NRestarts=0
IRC login confirmed
self-JOIN #boulets confirmed
WIT activity = 0
+Wit enabled channels = 0
With +Wit temporarily enabled on #boulets:
[WIT_DRYRUN] consider / eligible
[WIT_EMIT_DRYRUN] emit / authorized
[WIT_AI_DRYRUN] reply / model_reply
No IRC message was emitted.
The real sender and real transport adapter were loaded into the daemon, but there was still no runtime arm path.
A real model reply produced:
[WIT_EMIT_DRYRUN] ... action=emit reason=authorized
[WIT_SEND] ... action=no_send reason=kill_switch
This proved that the candidate reached the actual sender while the transport remained blocked.
WIT_SEND_ARMED mechanism loaded, default still OFFThe real DEV config contained no active WIT_SEND_ARMED key.
The runtime therefore interpreted:
missing key -> OFF
With +Wit temporarily ON, another real provider reply again produced:
emit / authorized
β no_send / kill_switch
So the new configuration-driven master switch was proven live before ever being enabled.
Only after all previous gates passed was the master switch temporarily armed.
Safety state before channel opt-in:
WIT_SEND_ARMED=1
+Wit enabled channels=0
PID unchanged
NRestarts=0
Then +Wit was enabled only on #boulets.
The test message was:
Un hibou qui découvre Linux pour la première fois commence par Debian ou par apprendre à utiliser sudo ?
And Mediabot replied for real:
Un hibou sage commencerait par sudo, histoire de ne pas se retrouver avec les permissions dβune souris π¦
The application log proves the entire path:
[WIT_DRYRUN]
action=consider
reason=eligible
[WIT_EMIT_DRYRUN]
action=emit
reason=authorized
reply_chars=104
reply_bytes=107
request_generation=7
[WIT_SEND]
action=sent
reason=delivered
reply_chars=104
reply_bytes=107
request_generation=7
[WIT_AI_DRYRUN]
action=reply
reason=model_reply
provider=anthropic
model=claude-haiku-4-5-20251001
provider_fallback=0
model_fallback=0
reply_chars=104
That is the first confirmed proactive AI-generated IRC reply from the new Wit pipeline.
π¦ The owl finally left the tower.
The test did not leave Wit enabled.
Immediately afterward:
-Wit on #boulets
Then the master configuration key was removed again and the configuration reloaded.
Final state:
WIT_ENABLED_CHANNELS=0
WIT_SEND_ARMED active key count=0
parsed WIT_SEND_ARMED=0
PID=21094
NRestarts=0
service=active/running
The original DEV configuration was restored byte-for-byte.
A secure audit backup was retained outside the repository.
The first final full suite stopped at:
16761/16762 PASS
The single failing historical test was:
625_mb407_channels_canonical_lc_key.t
It detected two channel hash lookups in ConversationRuntimeState.pm that were functionally canonicalized by _channel_key(), but did not satisfy Mediabotβs historical explicit source rule.
The two accesses were changed from:
$self->{channels}{$key}
to:
$self->{channels}{lc $key}
The exact regression set then passed:
94/94 PASS
Because source bytes changed, the earlier full-suite result was correctly discarded as a commit proof.
A completely new final full was required.
The final pre-commit gate produced:
Targeted AI/Wit : 597/597 PASS
Fast lane : 6250/6250 PASS
Full suite : 16762/16762 PASS
Gitleaks : no leaks found
git diff check : PASS
Wit errors : 0
Real send proof : present
Final runtime safety state:
+Wit enabled channels : 0
WIT_SEND_ARMED : OFF
DEV PID : 21094
NRestarts : 0
service : active/running
The source manifest was also frozen before the final full and compared afterward:
66004975ec2312ef62d9e75e70416b144d1f332022029884e8626695b79183d4
It remained unchanged.
MB701 also tightened the pre-commit workflow.
The final VERSION was reserved before the successful full suite:
3.4dev-20260826_085234
The same timestamp was passed back to commit.sh:
COMMIT_VERSION_DATE_TAG=20260826_085234
So commit.sh rewrote VERSION with identical bytes instead of silently changing the source after the full-suite proof.
This avoids invalidating the final test evidence.
MB701 was committed and pushed as:
2b29421
Commit message:
MB701 π¦β¨ let Wit finally speak β guarded AI conversation crosses the Hogwarts gates safely π°πͺ
Git summary:
16 files changed
2350 insertions
1 deletion
New files include:
Mediabot/AI/ConversationEmission.pm
Mediabot/AI/ConversationRuntimeState.pm
Mediabot/AI/ConversationSender.pm
t/cases/930_mb701_wit_emission_contract.t
t/cases/931_mb701_wit_runtime_state.t
t/cases/932_mb701_wit_late_emission_dryrun.t
t/cases/933_mb701_wit_late_revocation.t
t/cases/934_mb701_wit_sender_contract.t
t/cases/935_mb701_wit_sender_disarmed_runtime.t
t/cases/936_mb701_wit_runtime_arm_config.t
MB701 is not simply βAI can send messages nowβ.
It establishes a reusable safety architecture for any future asynchronous feature that wants to act on IRC after delayed computation.
The important ideas are now reusable:
capture generation
revalidate late
fail closed
separate policy from transport
separate per-channel opt-in from master enablement
rate-limit actual effects independently
keep logs metadata-only
make revocation deterministic
prove default-OFF in the real daemon before enabling anything
Wit can finally speak.
But unlike a reckless first-year wizard waving a wand in the Great Hall, it now has to pass every door, portrait, prefect and permission spell before a single word reaches IRC. π°π¦β¨
You must be logged in to reply.