v0.1.6: switch to IRC-native bare <nick>: mention syntax
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)
package.sgl | 2 +-
src/apiary/tools.sgl | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------
test/test-tools.sgl | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 200 insertions(+), 46 deletions(-)package.sglmodified
(package name: "apiary" version: "0.1.5" version: "0.1.6" description: "MCP server for Enclave-based agent coordination — leader/worker hive runtime" url: "https://codeberg.org/sigil/apiary" license: "BSD-3-Clause"src/apiary/tools.sglmodified
((str-ci=? nick (car lst)) (remove-ci nick (cdr lst))) (else (cons (car lst) (remove-ci nick (cdr lst)))))) ;;; Parse a strict `@<nick>: <body>` mention prefix. Returns ;;; (values addressee body) when the text matches; (values #f text) ;;; otherwise. The `@` is mandatory: bare `nick: body` is treated ;;; as a broadcast (no addressee), as is anything that doesn't ;;; have a contiguous run of nick chars between `@` and `: `. ;;; Parse a mention prefix from a channel message. Returns ;;; (values addressee body) when the text matches a mention form; ;;; (values #f text) otherwise. ;;; ;;; Accepted nick chars: alphanumerics, dash, underscore. (define (parse-mention-prefix text) (: any? -> any?) (cond ((not (string? text)) (values #f text)) (else (let ((len (string-length text))) (cond ((or (= len 0) (not (char=? (string-ref text 0) #\@))) (values #f text)) (else (let scan ((i 1)) (cond ((>= i len) (values #f text)) ((char=? (string-ref text i) #\:) (cond ;; Need at least one nick char between @ and : ((<= i 1) (values #f text)) ;; Need ": " (colon followed by space) ((or (>= (+ i 1) len) (not (char=? (string-ref text (+ i 1)) #\space))) (values #f text)) (else (values (substring text 1 i) (substring text (+ i 2) len))))) ((nick-char? (string-ref text i)) (scan (+ i 1))) (else (values #f text)))))))))) ;;; Format a mention for the IRC wire: `@<nick>: <body>`. ;;; Two forms recognized: ;;; ;;; 1. IRC-native bare prefix: `<my-nick>: <body>` (case-insensitive ;;; match against the bot's own nick passed via `opt-my-nick`). ;;; This is the natural IRC convention used by Senpai, Goguma, ;;; irssi, gamja, the Lounge, etc. for 30+ years. ;;; ;;; 2. Legacy `@<token>: <body>` (Slack/Discord-style explicit ;;; marker). Recognized for ANY token to preserve backward-compat ;;; during the v0.2.x deprecation window. Will be removed in ;;; v0.3.0. ;;; ;;; Without `opt-my-nick`, only legacy form is recognized — bare ;;; `<token>: <body>` is treated as broadcast. This avoids ;;; misclassifying prose like "note: write this down" or data like ;;; "key:value" as a mention. ;;; ;;; The bot's own nick is the only bare-form addressee recognized. ;;; Bare `<other-nick>: <body>` is broadcast — apiary doesn't track ;;; channel membership, so without the my-nick gate any single-word ;;; prose would be misclassified. (A future enhancement could add ;;; full channel-member tracking via NAMES/JOIN/PART/QUIT to ;;; recognize mentions to other known nicks; that's tracked ;;; separately.) ;;; ;;; Accepted nick chars (legacy form): alphanumerics, dash, ;;; underscore. The bare form admits any string-equal match against ;;; my-nick, so the recognized character class follows whatever the ;;; IRC server allowed in the registered nick. (define (parse-mention-prefix text . opt-my-nick) (let ((my-nick (if (null? opt-my-nick) #f (car opt-my-nick)))) (cond ((not (string? text)) (values #f text)) ((= (string-length text) 0) (values #f text)) ;; Legacy @<token>: <body> form ((char=? (string-ref text 0) #\@) (parse-at-mention text)) ;; Bare <my-nick>: <body> form (only when my-nick is provided) ((string? my-nick) (parse-bare-self-mention text my-nick)) (else (values #f text))))) ;; Legacy @<token>: <body>: returns (values addressee body) or ;; (values #f text). Same strict-nick-char rule as before the ;; v0.1.6 IRC-native switch. (define (parse-at-mention text) (let ((len (string-length text))) (let scan ((i 1)) (cond ((>= i len) (values #f text)) ((char=? (string-ref text i) #\:) (cond ;; Need at least one nick char between @ and : ((<= i 1) (values #f text)) ;; Need ": " (colon followed by space) ((or (>= (+ i 1) len) (not (char=? (string-ref text (+ i 1)) #\space))) (values #f text)) (else (values (substring text 1 i) (substring text (+ i 2) len))))) ((nick-char? (string-ref text i)) (scan (+ i 1))) (else (values #f text)))))) ;; Bare <my-nick>: <body>: matches IFF the leading token (chars up ;; to the first colon) is non-empty, single-word, equals my-nick ;; case-insensitively, and is followed by exactly ": ". ;; ;; "alice: hi" + my-nick="alice" → ("alice" . "hi") ;; "Alice: hi" + my-nick="alice" → ("alice" . "hi") (case-insens) ;; "alice: hi" + my-nick="bob" → broadcast ;; "alice:hi" + my-nick="alice" → broadcast (no space) ;; "alice:" + my-nick="alice" → broadcast (empty body, no space) ;; "alice : hi" + any → broadcast (whitespace in token) (define (parse-bare-self-mention text my-nick) (let ((len (string-length text)) (nick-len (string-length my-nick))) (cond ;; Need at least my-nick + ": " + something (or empty body). ;; Minimum: my-nick + ":" + " " = nick-len + 2. ((< len (+ nick-len 2)) (values #f text)) ;; Must end with exactly ": " separator at offset nick-len. ((not (char=? (string-ref text nick-len) #\:)) (values #f text)) ((not (char=? (string-ref text (+ nick-len 1)) #\space)) (values #f text)) ;; Token must be a single word — no whitespace. ((let ws-scan ((i 0)) (cond ((>= i nick-len) #f) ; got through, no whitespace ((char-whitespace? (string-ref text i)) #t) (else (ws-scan (+ i 1))))) (values #f text)) ;; Case-insensitive match against my-nick. ((str-ci=? (substring text 0 nick-len) my-nick) (values my-nick (substring text (+ nick-len 2) len))) (else (values #f text))))) ;;; Format a mention for the IRC wire: `<nick>: <body>`. The bare ;;; form is the IRC-native convention; clients (Senpai, Goguma, ;;; irssi, gamja, the Lounge) highlight messages whose first word ;;; matches the user's nick. The pre-v0.1.6 `@<nick>: ` form is ;;; still recognized inbound during the deprecation window. (define (format-mention nick body) (: string? string? -> string?) (string-append +mention-marker+ nick +mention-separator+ body)) (string-append nick +mention-separator+ body)) ;; ============================================================ ;; Internal: trusted-set predicate + dynamic-set mutation (emoji . ,react-emoji) (target-msgid . ,react-target-msgid))))) (else (let-values (((addressee body) (parse-mention-prefix text))) (let-values (((addressee body) (if (string? my-nick) (parse-mention-prefix text my-nick) (parse-mention-prefix text)))) (cond ;; Mention to someone else — ignore ((and addresseetest/test-tools.sglmodified
(assert-equal "worker-bot_3" addressee) (assert-equal "ack" body)))) (test "rejects bare nick:body without leading @" (test "rejects bare nick:body when my-nick not provided (legacy mode)" ;; Without my-nick context, only the legacy @-prefixed form is ;; recognized — bare nick:body falls through to broadcast. (call-with-values (lambda () (parse-mention-prefix "alice: hello")) (lambda (addressee body) (assert-false body)))));; ============================================================;; Mention formatter;; IRC-native bare mention parsing (v0.1.6+);;;; With my-nick passed, parse-mention-prefix recognizes the IRC-native;; bare `<my-nick>: <body>` form. Tokens that don't match my-nick fall;; through to broadcast — apiary doesn't track channel membership, so;; the my-nick gate is the load-bearing safety net against;; misclassifying prose like "note: write this down" as a mention.;; ============================================================(test-group "parse-mention-prefix (IRC-native bare form)" (test "bare nick:body is mention when nick equals my-nick" (call-with-values (lambda () (parse-mention-prefix "quinn: ping" "quinn")) (lambda (addressee body) (assert-equal "quinn" addressee) (assert-equal "ping" body)))) (test "bare match is case-insensitive" (call-with-values (lambda () (parse-mention-prefix "Quinn: PING" "quinn")) (lambda (addressee body) (assert-equal "quinn" addressee) (assert-equal "PING" body)))) (test "bare nick:body is broadcast when nick != my-nick" (call-with-values (lambda () (parse-mention-prefix "alice: hello" "quinn")) (lambda (addressee body) (assert-false addressee) (assert-equal "alice: hello" body)))) (test "broadcast: prose-shaped nick:body (not actually a mention)" (call-with-values (lambda () (parse-mention-prefix "note: write this down" "quinn")) (lambda (addressee body) (assert-false addressee) (assert-equal "note: write this down" body)))) (test "broadcast: token has whitespace (foo bar: ...)" (call-with-values (lambda () (parse-mention-prefix "foo bar: hello" "foo")) (lambda (addressee body) (assert-false addressee) (assert-equal "foo bar: hello" body)))) (test "broadcast: no space after colon (key:value)" (call-with-values (lambda () (parse-mention-prefix "quinn:value" "quinn")) (lambda (addressee body) (assert-false addressee) (assert-equal "quinn:value" body)))) (test "broadcast: mid-line nick: pattern is not a mention" (call-with-values (lambda () (parse-mention-prefix "Hello, quinn: how are you?" "quinn")) (lambda (addressee body) (assert-false addressee) (assert-equal "Hello, quinn: how are you?" body)))) (test "legacy @<nick>: form still parsed for any nick (deprecation)" ;; During the v0.2.x deprecation window, legacy @-prefixed mentions ;; continue to parse for any token regardless of my-nick. Removed ;; in v0.3.0 cleanup. (call-with-values (lambda () (parse-mention-prefix "@bee-3: ack" "quinn")) (lambda (addressee body) (assert-equal "bee-3" addressee) (assert-equal "ack" body)))) (test "bare body may be empty" (call-with-values (lambda () (parse-mention-prefix "quinn: " "quinn")) (lambda (addressee body) (assert-equal "quinn" addressee) (assert-equal "" body)))));; ============================================================;; Mention formatter (v0.1.6: bare nick: form, no @ prefix);; ============================================================(test-group "format-mention" (test "wraps body with @<nick>: prefix" (assert-equal "@bee-3: please proceed" (test "wraps body with bare <nick>: prefix (no @)" (assert-equal "bee-3: please proceed" (format-mention "bee-3" "please proceed"))) (test "round-trips through parse-mention-prefix" (test "round-trips through parse-mention-prefix when my-nick matches" (call-with-values (lambda () (parse-mention-prefix (format-mention "worker-7" "ack"))) (format-mention "worker-7" "ack") "worker-7")) (lambda (addressee body) (assert-equal "worker-7" addressee) (assert-equal "ack" body)))))