Files
hermes-agent/gateway/platforms/signal_format.py
T
Teknium d6f954f510 refactor(signal): fence realigned tables so the code-block pass owns every MONOSPACE range
The salvaged detector recorded a second MONOSPACE range from a hand-rolled
position walker and then had to rebase earlier ranges by the padding delta
(the contributor's follow-up commit). Two range bookkeepers over one text is
where the bugs were: a pipe table inside a fenced code block was realigned
AND double-styled, because the walker ran after the fences were stripped and
could no longer tell code from table.

Run table realignment on the raw markdown instead, outside fences only, and
wrap each aligned table in a fence. The existing code-block pass then emits
exactly one MONOSPACE range per table and the existing removal/adjust
machinery shifts every later range — no second offset model, no rebase loop.
The contributor's fixup commit becomes unnecessary and is not carried.

Tests trimmed to two invariants: a CJK/emoji table aligns by display width
with correct UTF-16 offsets for the range and the style after it, and a
fenced pipe table stays code while a real table before it keeps a later code
range pointed at its own text (also covers the lone-pipe negative).

Refs #106181
2026-09-09 11:05:15 -07:00

131 lines
5.8 KiB
Python

"""Shared Signal formatting helpers: Markdown → Signal native formatting lives here so both the live
adapter and the standalone send paths emit the same bodyRanges."""
from __future__ import annotations
import re
from agent.markdown_tables import is_table_divider, realign_markdown_tables
# Signal has no fixed-width client area; this budgets for a phone screen in the app's
# monospace face. Tables wider than this fall back to realign_markdown_tables()'s
# vertical Key: value rendering rather than soft-wrapping mid-cell.
_TABLE_WIDTH = 40
_CODE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL)
_HEADING_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE)
_INLINE_PATTERNS = [
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), "BOLD"),
(re.compile(r"__(.+?)__", re.DOTALL), "BOLD"),
(re.compile(r"~~(.+?)~~", re.DOTALL), "STRIKETHROUGH"),
(re.compile(r"`(.+?)`"), "MONOSPACE"),
(re.compile(r"(?<!\*)\*(?!\*| )(.+?)(?<!\*)\*(?!\*)"), "ITALIC"),
(re.compile(r"(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)"), "ITALIC")]
def _utf16_len(s: str) -> int:
"""Length of *s* in UTF-16 code units."""
return len(s.encode("utf-16-le")) // 2
def _normalize_bullet_markers(source: str) -> str:
"""Replace Markdown bullet markers with plain Unicode bullets (Signal renders ``- item`` literally).
Fenced code blocks are kept byte-for-byte: list-looking lines inside code are code, not bullets."""
parts = re.split(r"(```.*?```)", source, flags=re.DOTALL)
return "".join(re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part) if idx % 2 == 0 else part
for idx, part in enumerate(parts))
def _fence_tables(source: str) -> str:
"""Re-align every GFM table outside a code fence and wrap it in a fence, so the code-block pass
in markdown_to_signal() records one MONOSPACE range over the aligned block and shifts every
later range for it. Fenced regions are left alone: a pipe table inside a code block is code."""
parts = re.split(r"(```.*?```)", source, flags=re.DOTALL)
return "".join(part if idx % 2 else _fence_unfenced_tables(part) for idx, part in enumerate(parts))
def _fence_unfenced_tables(text: str) -> str:
if "|" not in text:
return text
lines, out = text.split("\n"), []
i, n = 0, len(lines)
while i < n:
# Same block rule as realign_markdown_tables(): header row, divider, contiguous pipe rows.
if "|" in lines[i] and i + 1 < n and is_table_divider(lines[i + 1]):
j = i + 2
while j < n and "|" in lines[j] and lines[j].strip():
j += 1
out += ["```", realign_markdown_tables("\n".join(lines[i:j]), _TABLE_WIDTH), "```"]
i = j
continue
out.append(lines[i])
i += 1
return "\n".join(out)
def markdown_to_signal(text: str) -> tuple[str, list[str]]:
"""Convert markdown to plain text + Signal textStyles list. Signal uses ``bodyRanges`` (signal-cli
``textStyle`` / ``textStyles`` params) as ``start:length:STYLE`` with positions in UTF-16 code units.
Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE."""
text = _fence_tables(_normalize_bullet_markers(re.sub(r"\n{3,}", "\n\n", text).strip()))
styles: list[tuple[int, int, str]] = []
while match := _CODE_BLOCK_RE.search(text):
inner = match.group(1).rstrip("\n")
styles.append((match.start(), len(inner), "MONOSPACE"))
text = text[: match.start()] + inner + text[match.end() :]
new_text, last_end = "", 0
for match in _HEADING_RE.finditer(text):
new_text += text[last_end : match.start()]
eol = text.find("\n", match.end())
if eol == -1:
eol = len(text)
heading_text = text[match.end() : eol]
styles.append((len(new_text), len(heading_text), "BOLD"))
new_text += heading_text
last_end = eol
text = new_text + text[last_end:]
# Inline markers: first pattern to claim a span wins; later overlapping matches are dropped.
all_matches: list[tuple[int, int, int, int, str]] = []
occupied: list[tuple[int, int]] = []
for pattern, style in _INLINE_PATTERNS:
for match in pattern.finditer(text):
ms, me = match.start(), match.end()
if not any(ms < oe and me > os for os, oe in occupied):
all_matches.append((ms, me, match.start(1), match.end(1), style))
occupied.append((ms, me))
all_matches.sort()
# Strip the markers, recording (pos, len) removals so earlier block/heading ranges can be
# shifted, and capturing inline ranges in the stripped text.
result, last_end = "", 0
removals: list[tuple[int, int]] = []
inline_styles: list[tuple[int, int, str]] = []
for ms, me, g1s, g1e, style in all_matches:
if g1s > ms:
removals.append((ms, g1s - ms))
if me > g1e:
removals.append((g1e, me - g1e))
result += text[last_end:ms]
inner = text[g1s:g1e]
inline_styles.append((len(result), len(inner), style))
result += inner
last_end = me
removals.sort()
def _adjust(pos: int) -> int:
shift = 0
for remove_pos, remove_len in removals:
if remove_pos >= pos:
break
shift += min(remove_len, pos - remove_pos)
return pos - shift
adjusted_prior = [(_adjust(start), _adjust(start + length) - _adjust(start), style)
for start, length, style in styles if _adjust(start + length) > _adjust(start)]
text = result + text[last_end:]
style_strings: list[str] = []
for cp_start, cp_len, style_type in sorted(adjusted_prior + inline_styles):
if 0 <= cp_start and cp_start + cp_len <= len(text):
u16_start, u16_len = _utf16_len(text[:cp_start]), _utf16_len(text[cp_start : cp_start + cp_len])
style_strings.append(f"{u16_start}:{u16_len}:{style_type}")
return text, style_strings