fix(telegram): wrap markdown pipe tables in fenced code blocks

Telegram does not support markdown pipe tables. Detect pipe tables (header + separator + body rows) in both the MarkdownV2 and HTML parsers and wrap them in fenced code blocks so they render with monospace alignment.

Also adds a kernel identity instruction telling the LLM to avoid pipe tables in Telegram output.
This commit is contained in:
cj 2026-05-01 05:12:02 +00:00
parent 6e1fab80e2
commit f54e42b9bd
5 changed files with 64 additions and 2 deletions

View file

@ -116,7 +116,7 @@ func (cb *ContextBuilder) getIdentity() string {
version := config.FormatVersion() version := config.FormatVersion()
return fmt.Sprintf( return fmt.Sprintf(
`# picoclaw 🦞 (%s) `# picoclaw 🦞 (%s)
You are picoclaw, a helpful AI assistant. You are picoclaw, a helpful AI assistant.
@ -134,7 +134,9 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
5. **Telegram output** Telegram does not support markdown tables. If you need to present tabular data, use a fenced code block or a structured list instead. Markdown pipe tables will be automatically wrapped in code blocks, but it is better to avoid them entirely.`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
} }

View file

@ -5,6 +5,22 @@ import (
"strings" "strings"
) )
// reTable matches a markdown pipe table (header line + separator line + at least one body row).
// The (?m) flag makes ^ match at line boundaries.
// Trailing newline(s) are deliberately excluded from the match so that blank lines
// between the table and following content are preserved.
var reTable = regexp.MustCompile(`(?m)^(\|[^\n]+\|\r?\n\|[-:\|\s]+\|\r?\n(?:\|[^\n]+\|\r?\n?)+)`)
// wrapTablesInCodeBlocks wraps each pipe table in a fenced code block so that
// Telegram renders it with monospace alignment. This must run before any entity
// processing so the ``` delimiters are recognized as code blocks.
func wrapTablesInCodeBlocks(text string) string {
return reTable.ReplaceAllStringFunc(text, func(match string) string {
match = strings.TrimRight(match, "\r\n")
return "```\n" + match + "\n```"
})
}
// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2 // mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2
var mdV2SpecialChars = map[rune]bool{ var mdV2SpecialChars = map[rune]bool{
'*': true, '*': true,
@ -89,6 +105,9 @@ var verbatimEntities = map[string]bool{
// //
// Reference: https://core.telegram.org/bots/api#formatting-options // Reference: https://core.telegram.org/bots/api#formatting-options
func markdownToTelegramMarkdownV2(text string) string { func markdownToTelegramMarkdownV2(text string) string {
// 0. Wrap pipe tables in fenced code blocks so Telegram renders them legibly.
text = wrapTablesInCodeBlocks(text)
// 1. Convert Markdown headings → *escaped heading text* // 1. Convert Markdown headings → *escaped heading text*
text = reHeading.ReplaceAllStringFunc(text, func(match string) string { text = reHeading.ReplaceAllStringFunc(text, func(match string) string {
sub := reHeading.FindStringSubmatch(match) sub := reHeading.FindStringSubmatch(match)

View file

@ -56,6 +56,34 @@ func Test_markdownToTelegramMarkdownV2(t *testing.T) {
input: "<Market Capitalization>", input: "<Market Capitalization>",
expected: "\\<Market Capitalization\\>", expected: "\\<Market Capitalization\\>",
}, },
{
name: "pipe table is wrapped in code block",
input: "| Header 1 | Header 2 |\n" +
"|----------|----------|\n" +
"| Cell 1 | Cell 2 |\n" +
"| Cell 3 | Cell 4 |",
expected: "```\n" +
"| Header 1 | Header 2 |\n" +
"|----------|----------|\n" +
"| Cell 1 | Cell 2 |\n" +
"| Cell 3 | Cell 4 |\n" +
"```",
},
{
name: "pipe table surrounded by text",
input: "Some text\n\n" +
"| A | B |\n" +
"|---|---|\n" +
"| 1 | 2 |\n\n" +
"More text",
expected: "Some text\n\n" +
"```\n" +
"| A | B |\n" +
"|---|---|\n" +
"| 1 | 2 |\n" +
"```\n" +
"More text",
},
} }
for _, tc := range cases { for _, tc := range cases {

View file

@ -14,6 +14,9 @@ func markdownToTelegramHTML(text string) string {
return "" return ""
} }
// Wrap pipe tables in fenced code blocks so Telegram renders them legibly.
text = wrapTablesInCodeBlocks(text)
codeBlocks := extractCodeBlocks(text) codeBlocks := extractCodeBlocks(text)
text = codeBlocks.text text = codeBlocks.text

View file

@ -70,6 +70,16 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "```json\n{\n \"path\": \"README.md\"\n}\n```", input: "```json\n{\n \"path\": \"README.md\"\n}\n```",
expected: "<pre><code>{\n \"path\": \"README.md\"\n}\n</code></pre>", expected: "<pre><code>{\n \"path\": \"README.md\"\n}\n</code></pre>",
}, },
{
name: "pipe table is wrapped in code block",
input: "| Header 1 | Header 2 |\n" +
"|----------|----------|\n" +
"| Cell 1 | Cell 2 |",
expected: "<pre><code>| Header 1 | Header 2 |\n" +
"|----------|----------|\n" +
"| Cell 1 | Cell 2 |\n" +
"</code></pre>",
},
} }
for _, tc := range cases { for _, tc := range cases {