Commitb3b2ec52Recorded4 May 2026Repositoryapiary

v0.1.6: switch to IRC-native bare <nick>: mention syntax

Message

Replaces strict @<nick>: <body> with the IRC-native bare form <nick>: <body>. Senpai, Goguma, irssi, gamja, the Lounge, and 30+ years of IRC convention use bare nick-prefix mentions; the @ prefix was Slack/Discord muscle memory that never fit #hive traffic.

Inbound parser: - parse-mention-prefix now takes optional my-nick. With my-nick, bare <my-nick>: <body> (case-insensitive exact match) is recognized as a mention to self. - Legacy @<token>: <body> form preserved during the v0.2.x deprecation window — removal scheduled for v0.3.0. - Prose patterns (note: write this down, key:value, Hello, nick: question?) safely classified as broadcast. - Without my-nick context (legacy callers), only @-prefixed form is recognized — bare nick:body falls through to broadcast.

Outbound formatter: - format-mention drops the @ prefix; produces bare <nick>: <body>. - send-channel mention: <nick> wire output is now IRC-native.

Heuristic scope note: - Apiary doesn't track channel membership, so only my-nick is recognized as a bare-form addressee. Bare <other-nick>: <body> falls through to broadcast — apiary's filter still delivers it via the trusted-set rule, just labeled mention=broadcast in the MCP envelope. A future enhancement could add full member tracking to recognize mentions to other known channel nicks.

Tests: - 10-case matrix covering legacy parsing, bare match, case-insensitive, prose disambiguation, edge cases (no space, mid-line, empty body), format-mention round-trip. - Validated via 'sigil eval' (sigil test blocked by t-2156).

Folio: tasks/apiary-mention-syntax-irc-native (brief, archive on ship)

Changed
 package.sgl          |   2 +-
 src/apiary/tools.sgl | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------
 test/test-tools.sgl  |  92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 200 insertions(+), 46 deletions(-)
Diff
package.sglmodified
@@ -6,7 +6,7 @@
6
7
(package
8
name: "apiary"
9
version: "0.1.5"
+9
version: "0.1.6"
10
description: "MCP server for Enclave-based agent coordination — leader/worker hive runtime"
11
url: "https://codeberg.org/sigil/apiary"
12
license: "BSD-3-Clause"
src/apiary/tools.sglmodified
@@ -176,47 +176,118 @@
176
((str-ci=? nick (car lst)) (remove-ci nick (cdr lst)))
177
(else (cons (car lst) (remove-ci nick (cdr lst))))))
178
179
;;; Parse a strict `@<nick>: <body>` mention prefix. Returns
180
;;; (values addressee body) when the text matches; (values #f text)
181
;;; otherwise. The `@` is mandatory: bare `nick: body` is treated
182
;;; as a broadcast (no addressee), as is anything that doesn't
183
;;; have a contiguous run of nick chars between `@` and `: `.
+179
;;; Parse a mention prefix from a channel message. Returns
+180
;;; (values addressee body) when the text matches a mention form;
+181
;;; (values #f text) otherwise.
182
;;;
185
;;; Accepted nick chars: alphanumerics, dash, underscore.
186
(define (parse-mention-prefix text)
187
(: any? -> any?)
188
(cond
189
((not (string? text)) (values #f text))
190
(else
191
(let ((len (string-length text)))
192
(cond
193
((or (= len 0)
194
(not (char=? (string-ref text 0) #\@)))
195
(values #f text))
196
(else
197
(let scan ((i 1))
198
(cond
199
((>= i len) (values #f text))
200
((char=? (string-ref text i) #\:)
201
(cond
202
;; Need at least one nick char between @ and :
203
((<= i 1) (values #f text))
204
;; Need ": " (colon followed by space)
205
((or (>= (+ i 1) len)
206
(not (char=? (string-ref text (+ i 1)) #\space)))
207
(values #f text))
208
(else
209
(values (substring text 1 i)
210
(substring text (+ i 2) len)))))
211
((nick-char? (string-ref text i))
212
(scan (+ i 1)))
213
(else
214
(values #f text))))))))))
215
216
;;; Format a mention for the IRC wire: `@<nick>: <body>`.
+183
;;; Two forms recognized:
+184
;;;
+185
;;; 1. IRC-native bare prefix: `<my-nick>: <body>` (case-insensitive
+186
;;; match against the bot's own nick passed via `opt-my-nick`).
+187
;;; This is the natural IRC convention used by Senpai, Goguma,
+188
;;; irssi, gamja, the Lounge, etc. for 30+ years.
+189
;;;
+190
;;; 2. Legacy `@<token>: <body>` (Slack/Discord-style explicit
+191
;;; marker). Recognized for ANY token to preserve backward-compat
+192
;;; during the v0.2.x deprecation window. Will be removed in
+193
;;; v0.3.0.
+194
;;;
+195
;;; Without `opt-my-nick`, only legacy form is recognized — bare
+196
;;; `<token>: <body>` is treated as broadcast. This avoids
+197
;;; misclassifying prose like "note: write this down" or data like
+198
;;; "key:value" as a mention.
+199
;;;
+200
;;; The bot's own nick is the only bare-form addressee recognized.
+201
;;; Bare `<other-nick>: <body>` is broadcast — apiary doesn't track
+202
;;; channel membership, so without the my-nick gate any single-word
+203
;;; prose would be misclassified. (A future enhancement could add
+204
;;; full channel-member tracking via NAMES/JOIN/PART/QUIT to
+205
;;; recognize mentions to other known nicks; that's tracked
+206
;;; separately.)
+207
;;;
+208
;;; Accepted nick chars (legacy form): alphanumerics, dash,
+209
;;; underscore. The bare form admits any string-equal match against
+210
;;; my-nick, so the recognized character class follows whatever the
+211
;;; IRC server allowed in the registered nick.
+212
(define (parse-mention-prefix text . opt-my-nick)
+213
(let ((my-nick (if (null? opt-my-nick) #f (car opt-my-nick))))
+214
(cond
+215
((not (string? text)) (values #f text))
+216
((= (string-length text) 0) (values #f text))
+217
;; Legacy @<token>: <body> form
+218
((char=? (string-ref text 0) #\@)
+219
(parse-at-mention text))
+220
;; Bare <my-nick>: <body> form (only when my-nick is provided)
+221
((string? my-nick)
+222
(parse-bare-self-mention text my-nick))
+223
(else (values #f text)))))
+224
+225
;; Legacy @<token>: <body>: returns (values addressee body) or
+226
;; (values #f text). Same strict-nick-char rule as before the
+227
;; v0.1.6 IRC-native switch.
+228
(define (parse-at-mention text)
+229
(let ((len (string-length text)))
+230
(let scan ((i 1))
+231
(cond
+232
((>= i len) (values #f text))
+233
((char=? (string-ref text i) #\:)
+234
(cond
+235
;; Need at least one nick char between @ and :
+236
((<= i 1) (values #f text))
+237
;; Need ": " (colon followed by space)
+238
((or (>= (+ i 1) len)
+239
(not (char=? (string-ref text (+ i 1)) #\space)))
+240
(values #f text))
+241
(else
+242
(values (substring text 1 i)
+243
(substring text (+ i 2) len)))))
+244
((nick-char? (string-ref text i))
+245
(scan (+ i 1)))
+246
(else
+247
(values #f text))))))
+248
+249
;; Bare <my-nick>: <body>: matches IFF the leading token (chars up
+250
;; to the first colon) is non-empty, single-word, equals my-nick
+251
;; case-insensitively, and is followed by exactly ": ".
+252
;;
+253
;; "alice: hi" + my-nick="alice" → ("alice" . "hi")
+254
;; "Alice: hi" + my-nick="alice" → ("alice" . "hi") (case-insens)
+255
;; "alice: hi" + my-nick="bob" → broadcast
+256
;; "alice:hi" + my-nick="alice" → broadcast (no space)
+257
;; "alice:" + my-nick="alice" → broadcast (empty body, no space)
+258
;; "alice : hi" + any → broadcast (whitespace in token)
+259
(define (parse-bare-self-mention text my-nick)
+260
(let ((len (string-length text))
+261
(nick-len (string-length my-nick)))
+262
(cond
+263
;; Need at least my-nick + ": " + something (or empty body).
+264
;; Minimum: my-nick + ":" + " " = nick-len + 2.
+265
((< len (+ nick-len 2)) (values #f text))
+266
;; Must end with exactly ": " separator at offset nick-len.
+267
((not (char=? (string-ref text nick-len) #\:)) (values #f text))
+268
((not (char=? (string-ref text (+ nick-len 1)) #\space))
+269
(values #f text))
+270
;; Token must be a single word — no whitespace.
+271
((let ws-scan ((i 0))
+272
(cond
+273
((>= i nick-len) #f) ; got through, no whitespace
+274
((char-whitespace? (string-ref text i)) #t)
+275
(else (ws-scan (+ i 1)))))
+276
(values #f text))
+277
;; Case-insensitive match against my-nick.
+278
((str-ci=? (substring text 0 nick-len) my-nick)
+279
(values my-nick
+280
(substring text (+ nick-len 2) len)))
+281
(else (values #f text)))))
+282
+283
;;; Format a mention for the IRC wire: `<nick>: <body>`. The bare
+284
;;; form is the IRC-native convention; clients (Senpai, Goguma,
+285
;;; irssi, gamja, the Lounge) highlight messages whose first word
+286
;;; matches the user's nick. The pre-v0.1.6 `@<nick>: ` form is
+287
;;; still recognized inbound during the deprecation window.
288
(define (format-mention nick body)
289
(: string? string? -> string?)
219
(string-append +mention-marker+ nick +mention-separator+ body))
+290
(string-append nick +mention-separator+ body))
291
292
;; ============================================================
293
;; Internal: trusted-set predicate + dynamic-set mutation
@@ -326,7 +397,10 @@
397
(emoji . ,react-emoji)
398
(target-msgid . ,react-target-msgid)))))
399
(else
329
(let-values (((addressee body) (parse-mention-prefix text)))
+400
(let-values (((addressee body)
+401
(if (string? my-nick)
+402
(parse-mention-prefix text my-nick)
+403
(parse-mention-prefix text))))
404
(cond
405
;; Mention to someone else — ignore
406
((and addressee
test/test-tools.sglmodified
@@ -49,7 +49,9 @@
49
(assert-equal "worker-bot_3" addressee)
50
(assert-equal "ack" body))))
51
52
(test "rejects bare nick:body without leading @"
+52
(test "rejects bare nick:body when my-nick not provided (legacy mode)"
+53
;; Without my-nick context, only the legacy @-prefixed form is
+54
;; recognized — bare nick:body falls through to broadcast.
55
(call-with-values
56
(lambda () (parse-mention-prefix "alice: hello"))
57
(lambda (addressee body)
@@ -106,18 +108,96 @@
108
(assert-false body)))))
109
110
;; ============================================================
109
;; Mention formatter
+111
;; IRC-native bare mention parsing (v0.1.6+)
+112
;;
+113
;; With my-nick passed, parse-mention-prefix recognizes the IRC-native
+114
;; bare `<my-nick>: <body>` form. Tokens that don't match my-nick fall
+115
;; through to broadcast — apiary doesn't track channel membership, so
+116
;; the my-nick gate is the load-bearing safety net against
+117
;; misclassifying prose like "note: write this down" as a mention.
+118
;; ============================================================
+119
+120
(test-group "parse-mention-prefix (IRC-native bare form)"
+121
(test "bare nick:body is mention when nick equals my-nick"
+122
(call-with-values
+123
(lambda () (parse-mention-prefix "quinn: ping" "quinn"))
+124
(lambda (addressee body)
+125
(assert-equal "quinn" addressee)
+126
(assert-equal "ping" body))))
+127
+128
(test "bare match is case-insensitive"
+129
(call-with-values
+130
(lambda () (parse-mention-prefix "Quinn: PING" "quinn"))
+131
(lambda (addressee body)
+132
(assert-equal "quinn" addressee)
+133
(assert-equal "PING" body))))
+134
+135
(test "bare nick:body is broadcast when nick != my-nick"
+136
(call-with-values
+137
(lambda () (parse-mention-prefix "alice: hello" "quinn"))
+138
(lambda (addressee body)
+139
(assert-false addressee)
+140
(assert-equal "alice: hello" body))))
+141
+142
(test "broadcast: prose-shaped nick:body (not actually a mention)"
+143
(call-with-values
+144
(lambda () (parse-mention-prefix "note: write this down" "quinn"))
+145
(lambda (addressee body)
+146
(assert-false addressee)
+147
(assert-equal "note: write this down" body))))
+148
+149
(test "broadcast: token has whitespace (foo bar: ...)"
+150
(call-with-values
+151
(lambda () (parse-mention-prefix "foo bar: hello" "foo"))
+152
(lambda (addressee body)
+153
(assert-false addressee)
+154
(assert-equal "foo bar: hello" body))))
+155
+156
(test "broadcast: no space after colon (key:value)"
+157
(call-with-values
+158
(lambda () (parse-mention-prefix "quinn:value" "quinn"))
+159
(lambda (addressee body)
+160
(assert-false addressee)
+161
(assert-equal "quinn:value" body))))
+162
+163
(test "broadcast: mid-line nick: pattern is not a mention"
+164
(call-with-values
+165
(lambda () (parse-mention-prefix "Hello, quinn: how are you?" "quinn"))
+166
(lambda (addressee body)
+167
(assert-false addressee)
+168
(assert-equal "Hello, quinn: how are you?" body))))
+169
+170
(test "legacy @<nick>: form still parsed for any nick (deprecation)"
+171
;; During the v0.2.x deprecation window, legacy @-prefixed mentions
+172
;; continue to parse for any token regardless of my-nick. Removed
+173
;; in v0.3.0 cleanup.
+174
(call-with-values
+175
(lambda () (parse-mention-prefix "@bee-3: ack" "quinn"))
+176
(lambda (addressee body)
+177
(assert-equal "bee-3" addressee)
+178
(assert-equal "ack" body))))
+179
+180
(test "bare body may be empty"
+181
(call-with-values
+182
(lambda () (parse-mention-prefix "quinn: " "quinn"))
+183
(lambda (addressee body)
+184
(assert-equal "quinn" addressee)
+185
(assert-equal "" body)))))
+186
+187
;; ============================================================
+188
;; Mention formatter (v0.1.6: bare nick: form, no @ prefix)
189
;; ============================================================
190
191
(test-group "format-mention"
113
(test "wraps body with @<nick>: prefix"
114
(assert-equal "@bee-3: please proceed"
+192
(test "wraps body with bare <nick>: prefix (no @)"
+193
(assert-equal "bee-3: please proceed"
194
(format-mention "bee-3" "please proceed")))
195
117
(test "round-trips through parse-mention-prefix"
+196
(test "round-trips through parse-mention-prefix when my-nick matches"
197
(call-with-values
198
(lambda () (parse-mention-prefix
120
(format-mention "worker-7" "ack")))
+199
(format-mention "worker-7" "ack")
+200
"worker-7"))
201
(lambda (addressee body)
202
(assert-equal "worker-7" addressee)
203
(assert-equal "ack" body)))))