Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Releases/v5.0.0/.claude/PAI/PULSE/modules/markdown-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Markdown → Telegram HTML converter.
*
* Telegram's parse_mode "HTML" supports a small tag set: b, i, u, s, code,
* pre, a, tg-spoiler. It does not render headers, lists, or tables. This
* helper converts a useful subset of common markdown emitted by the DA into
* that tag set so prose renders cleanly on phones.
*
* HTML special chars are escaped first so model-emitted '<', '>', '&' survive
* verbatim inside the message body.
*
* Design choices:
* - Code blocks and inline code are stashed to placeholders BEFORE other
* transforms run, so bold/italic/etc. cannot leak into code content.
* - Italic markers (* and _) require punctuation/whitespace/tag-boundary on
* both sides so snake_case identifiers and arithmetic stars are not
* mangled, while still allowing italic to nest inside bold tags.
* - Unbalanced markers render as literal characters — no broken HTML can be
* emitted, so chunk-splitting before conversion is safe.
* - Headers and bullets collapse to <b> lines and "• " glyphs respectively,
* since Telegram does not natively render either.
*/
export function mdToHtml(text: string): string {
let out = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")

// Stash code blocks first so subsequent bold/italic/etc. cannot mutate
// their contents. Placeholder sentinels cannot appear in normal model
// output and no other regex below matches against them.
const stash: string[] = []
const place = (html: string): string => {
const i = stash.length
stash.push(html)
return ` CODE${i} `
}

out = out.replace(
/```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g,
(_, code) => place(`<pre>${code.replace(/\n$/, "")}</pre>`),
)
out = out.replace(/`([^`\n]+)`/g, (_, code) => place(`<code>${code}</code>`))

// Links [text](url)
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
// Bold **x**
out = out.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>")
// Italic *x* / _x_ — boundary includes HTML tag chars (<, >) so italic can
// nest inside bold (e.g. **a _b_**) while still rejecting snake_case.
out = out.replace(/(^|[\s(>])\*([^*\n]+)\*(?=[\s).,!?:;<]|$)/g, "$1<i>$2</i>")
out = out.replace(/(^|[\s(>])_([^_\n]+)_(?=[\s).,!?:;<]|$)/g, "$1<i>$2</i>")
// Strikethrough ~~x~~
out = out.replace(/~~([^~\n]+)~~/g, "<s>$1</s>")
// ATX headers
out = out.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>")
// Bullet markers
out = out.replace(/^\s*[-*+]\s+/gm, "• ")

// Restore code placeholders.
out = out.replace(/ CODE(\d+) /g, (_, i) => stash[Number(i)]!)
return out
}
104 changes: 104 additions & 0 deletions Releases/v5.0.0/.claude/PAI/PULSE/modules/telegram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Tests for the mdToHtml helper used by the Telegram module.
*
* Run with: bun test Releases/v5.0.0/.claude/PAI/PULSE/modules/telegram.test.ts
*/
import { describe, expect, test } from "bun:test"
import { mdToHtml } from "./markdown-html"

describe("mdToHtml", () => {
test("HTML special chars are escaped first", () => {
expect(mdToHtml("a < b & c > d")).toBe("a &lt; b &amp; c &gt; d")
})

test("bold **x** → <b>x</b>", () => {
expect(mdToHtml("this is **bold** here")).toBe("this is <b>bold</b> here")
})

test("italic *x* with boundary → <i>x</i>", () => {
expect(mdToHtml("an *emphasised* word")).toBe("an <i>emphasised</i> word")
})

test("italic _x_ with boundary → <i>x</i>", () => {
expect(mdToHtml("an _emphasised_ word")).toBe("an <i>emphasised</i> word")
})

test("snake_case underscores survive intact", () => {
expect(mdToHtml("call connection_reference_name twice")).toBe(
"call connection_reference_name twice",
)
})

test("inline `code` → <code>code</code>", () => {
expect(mdToHtml("run `bun test` now")).toBe("run <code>bun test</code> now")
})

test("brackets inside inline code are escaped", () => {
expect(mdToHtml("use `arr<T>` here")).toBe("use <code>arr&lt;T&gt;</code> here")
})

test("fenced code blocks → <pre>", () => {
const input = "```ts\nlet x = 1\nlet y = 2\n```"
expect(mdToHtml(input)).toBe("<pre>let x = 1\nlet y = 2</pre>")
})

test("fenced code with no language tag", () => {
expect(mdToHtml("```\nhello\n```")).toBe("<pre>hello</pre>")
})

test("links [text](url) → <a href>", () => {
expect(mdToHtml("see [docs](https://example.com) for more")).toBe(
'see <a href="https://example.com">docs</a> for more',
)
})

test("nested bold + italic", () => {
expect(mdToHtml("**bold _and italic_**")).toBe("<b>bold <i>and italic</i></b>")
})

test("strikethrough ~~x~~ → <s>", () => {
expect(mdToHtml("this is ~~gone~~ now")).toBe("this is <s>gone</s> now")
})

test("ATX headers become bold lines", () => {
expect(mdToHtml("# Heading\nbody")).toBe("<b>Heading</b>\nbody")
expect(mdToHtml("### Sub")).toBe("<b>Sub</b>")
})

test("bullet markers become bullet glyphs", () => {
expect(mdToHtml("- one\n- two\n* three\n+ four")).toBe(
"• one\n• two\n• three\n• four",
)
})

test("unbalanced bold marker renders as literal asterisks (no broken HTML)", () => {
expect(mdToHtml("starting **but no close")).toBe("starting **but no close")
})

test("unbalanced italic underscore renders as literal", () => {
expect(mdToHtml("snake_case at end_")).toBe("snake_case at end_")
})

test("code block content is not re-processed for inline markdown", () => {
const input = "```\n**not bold**\n```"
expect(mdToHtml(input)).toBe("<pre>**not bold**</pre>")
})

test("URL with underscores is not italicised", () => {
expect(mdToHtml("[link](https://x.com/my_path_here)")).toBe(
'<a href="https://x.com/my_path_here">link</a>',
)
})

test("multiple bold spans in one line", () => {
expect(mdToHtml("**a** and **b**")).toBe("<b>a</b> and <b>b</b>")
})

test("empty input returns empty string", () => {
expect(mdToHtml("")).toBe("")
})

test("plain prose with no markdown is unchanged", () => {
expect(mdToHtml("just a normal sentence.")).toBe("just a normal sentence.")
})
})
59 changes: 42 additions & 17 deletions Releases/v5.0.0/.claude/PAI/PULSE/modules/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Bot } from "grammy"
import { query } from "@anthropic-ai/claude-agent-sdk"
import { ConversationStore } from "../lib/conversation"
import { sanitize, analyzeForInjection } from "../lib/sanitize"
import { mdToHtml } from "./markdown-html"
import { join } from "path"
import { appendFile, mkdir } from "fs/promises"

Expand All @@ -31,6 +32,10 @@ export interface TelegramConfig {
max_turns?: number
sdk_timeout_ms?: number
edit_interval_ms?: number
// Rendering mode for outgoing messages. "html" converts a useful subset of
// markdown to Telegram HTML (bold, italic, code, pre, links, headers,
// bullets, strikethrough); "plain" sends raw text. Defaults to "html".
markdown_mode?: "html" | "plain"
}

// ── Constants ──
Expand Down Expand Up @@ -114,6 +119,7 @@ export async function startTelegram(config: TelegramConfig): Promise<void> {
const maxTurns = config.max_turns ?? 25
const sdkTimeoutMs = config.sdk_timeout_ms ?? 120_000
const editIntervalMs = config.edit_interval_ms ?? 800
const markdownMode = config.markdown_mode ?? "html"

// Ensure directories
await mkdir(STATE_DIR, { recursive: true })
Expand Down Expand Up @@ -204,14 +210,22 @@ You are {{DA_NAME}}, responding via Telegram. {{PRINCIPAL_NAME}} is messaging yo

CRITICAL RULES FOR TELEGRAM MODE:
- IGNORE all ALGORITHM/NATIVE/MINIMAL format templates from CLAUDE.md. Those are for terminal sessions only.
- NO format headers (no ════, no 🗒️, no ━━━, no ISC criteria, no phase markers)
- NO emoji prefixes, NO bullet formatting
- Speak as {{DA_NAME}} — first person, natural, conversational, like talking to a friend
- Keep responses under 200 words
- No code blocks unless {{PRINCIPAL_NAME}} specifically asks for code
- NEVER use voice notification curls (no http://localhost:31337/notify calls)
- You have ALL PAI capabilities — skills, email, calendar, lights, everything
- When doing tasks, do them and confirm briefly what you did`,
- NO PAI box headers (no ════, no 🗒️, no ━━━, no ISC criteria, no phase markers) — those don't render in Telegram and look like noise.
- USE MARKDOWN FREELY — it renders. Telegram converts your markdown to native formatting before display:
· **bold** → bold
· *italic* or _italic_ → italic
· \`inline code\` → monospace
· triple-backtick fenced blocks → preformatted code blocks
· [link text](https://url) → tappable link
· - bullet or * bullet → bullet list
· # / ## / ### headings → bold heading lines
· ~~strike~~ → strikethrough
- Speak as {{DA_NAME}} — first person, natural, conversational, like talking to a friend.
- Keep responses under 200 words unless {{PRINCIPAL_NAME}} explicitly asks for more.
- Use code blocks when sharing commands, paths, JSON, or anything copy-paste; use bold for emphasis; use bullet lists when listing 3+ items.
- NEVER use voice notification curls (no http://localhost:31337/notify calls).
- You have ALL PAI capabilities — skills, email, calendar, lights, everything.
- When doing tasks, do them and confirm briefly what you did.`,
},
}

Expand Down Expand Up @@ -295,29 +309,40 @@ CRITICAL RULES FOR TELEGRAM MODE:
log("error", "Empty response from SDK")
}

// Final clean message
// Final clean message — render markdown if configured, with plain-text
// fallback for any edge case where Telegram rejects the HTML.
const useHtml = markdownMode === "html"
const render = (s: string) => (useHtml ? mdToHtml(s) : s)
const sendOpts = useHtml ? { parse_mode: "HTML" as const } : undefined

if (fullText.length <= MAX_TELEGRAM_LENGTH) {
const rendered = render(fullText)
if (messageId) {
await ctx.api.editMessageText(chatId, messageId, fullText).catch(() => {})
await ctx.api.editMessageText(chatId, messageId, rendered, sendOpts)
.catch(() => ctx.api.editMessageText(chatId, messageId, fullText).catch(() => {}))
} else {
await ctx.reply(fullText)
await ctx.reply(rendered, sendOpts).catch(() => ctx.reply(fullText))
}
} else {
// Split long messages
// Split long messages on 4096-char boundary. Splitting raw text before
// markdown conversion is safe: mdToHtml requires balanced markers, so
// a marker split across two chunks just renders as literal characters.
const chunks: string[] = []
let remaining = fullText
while (remaining.length > 0) {
chunks.push(remaining.slice(0, MAX_TELEGRAM_LENGTH))
remaining = remaining.slice(MAX_TELEGRAM_LENGTH)
}
const rendered = chunks.map(render)
if (messageId) {
await ctx.api.editMessageText(chatId, messageId, chunks[0]!).catch(() => {})
for (const chunk of chunks.slice(1)) {
await ctx.reply(chunk)
await ctx.api.editMessageText(chatId, messageId, rendered[0]!, sendOpts)
.catch(() => ctx.api.editMessageText(chatId, messageId, chunks[0]!).catch(() => {}))
for (let i = 1; i < chunks.length; i++) {
await ctx.reply(rendered[i]!, sendOpts).catch(() => ctx.reply(chunks[i]!))
}
} else {
for (const chunk of chunks) {
await ctx.reply(chunk)
for (let i = 0; i < chunks.length; i++) {
await ctx.reply(rendered[i]!, sendOpts).catch(() => ctx.reply(chunks[i]!))
}
}
}
Expand Down