@phuetz/code-buddy 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -469,21 +469,44 @@ export async function registerAIMessageHandler(manager) {
|
|
|
469
469
|
catch (councilErr) {
|
|
470
470
|
lines.push(`❌ Council a échoué : ${councilErr instanceof Error ? councilErr.message : String(councilErr)}`);
|
|
471
471
|
}
|
|
472
|
-
// Telegram caps messages ~4096 chars; flush on line boundaries.
|
|
473
472
|
const full = lines.join('\n').trim() || '(aucune sortie)';
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
473
|
+
// Render the aligned tables (📊 détail par IA + learned ranking) in a
|
|
474
|
+
// monospace code block so Telegram's proportional font doesn't break the
|
|
475
|
+
// padded columns; the prose (the winning answer) stays normal text.
|
|
476
|
+
let tableStart = full.indexOf('📊 Détail par IA');
|
|
477
|
+
if (tableStart < 0)
|
|
478
|
+
tableStart = full.search(/Learned model ranking|No council history/);
|
|
479
|
+
const prose = tableStart >= 0 ? full.slice(0, tableStart).trim() : full;
|
|
480
|
+
const tables = tableStart >= 0 ? full.slice(tableStart).trim() : '';
|
|
481
|
+
// Telegram caps messages ~4096 chars; flush on line boundaries. Tables
|
|
482
|
+
// go in an HTML <pre> block (monospace) — HTML is the robust mode: inside
|
|
483
|
+
// <pre> only &, <, > need escaping (vs MarkdownV2's ~18 escapes / legacy
|
|
484
|
+
// Markdown's fragility). See Telegram Bot API "Formatting options".
|
|
485
|
+
const htmlEscape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
486
|
+
const sendChunked = async (text, mono) => {
|
|
487
|
+
if (!text)
|
|
488
|
+
return;
|
|
489
|
+
const limit = mono ? 3500 : 3800;
|
|
490
|
+
let buf = '';
|
|
491
|
+
const flush = async () => {
|
|
492
|
+
if (!buf)
|
|
493
|
+
return;
|
|
494
|
+
await channel.send({
|
|
495
|
+
channelId: message.channel.id,
|
|
496
|
+
content: mono ? '<pre>' + htmlEscape(buf) + '</pre>' : buf,
|
|
497
|
+
parseMode: mono ? 'html' : undefined,
|
|
498
|
+
});
|
|
478
499
|
buf = '';
|
|
500
|
+
};
|
|
501
|
+
for (const ln of text.split('\n')) {
|
|
502
|
+
if (buf.length + ln.length + 1 > limit)
|
|
503
|
+
await flush();
|
|
504
|
+
buf += (buf ? '\n' : '') + ln;
|
|
479
505
|
}
|
|
506
|
+
await flush();
|
|
480
507
|
};
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
await flush();
|
|
484
|
-
buf += (buf ? '\n' : '') + ln;
|
|
485
|
-
}
|
|
486
|
-
await flush();
|
|
508
|
+
await sendChunked(prose, false);
|
|
509
|
+
await sendChunked(tables, true);
|
|
487
510
|
return;
|
|
488
511
|
}
|
|
489
512
|
// Remote tool-approval over Telegram. A daemon has no interactive terminal,
|
|
@@ -539,12 +562,37 @@ export async function registerAIMessageHandler(manager) {
|
|
|
539
562
|
const entries = await agent.processUserMessage(message.content);
|
|
540
563
|
const lastEntry = entries[entries.length - 1];
|
|
541
564
|
const response = lastEntry ? String(lastEntry.content) : '';
|
|
542
|
-
// 6. Deliver reply
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
565
|
+
// 6. Deliver the reply. On Telegram, render the agent's markdown to the
|
|
566
|
+
// robust HTML subset (bold / code / tables / links) so it doesn't show
|
|
567
|
+
// raw; if Telegram rejects the HTML (success:false) fall back to plain
|
|
568
|
+
// text. Other channels keep native markdown (Discord/Slack render it).
|
|
569
|
+
if (channel.type === 'telegram' && response.trim()) {
|
|
570
|
+
const { renderTelegramHtml } = await import('../../rendering/telegram-html.js');
|
|
571
|
+
const chunks = renderTelegramHtml(response);
|
|
572
|
+
let ok = chunks.length > 0;
|
|
573
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
574
|
+
const res = await channel.send({
|
|
575
|
+
channelId: message.channel.id,
|
|
576
|
+
content: chunks[i],
|
|
577
|
+
parseMode: 'html',
|
|
578
|
+
replyTo: i === 0 ? message.id : undefined,
|
|
579
|
+
});
|
|
580
|
+
if (!res?.success) {
|
|
581
|
+
ok = false;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (!ok) {
|
|
586
|
+
await channel.send({ channelId: message.channel.id, content: response, replyTo: message.id });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
await channel.send({
|
|
591
|
+
channelId: message.channel.id,
|
|
592
|
+
content: response,
|
|
593
|
+
replyTo: message.id,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
548
596
|
// 7. If the user SPOKE (voice note), answer by voice too — mirror the
|
|
549
597
|
// modality. Best-effort: the text reply already landed, so a TTS/upload
|
|
550
598
|
// failure (or a channel without voice support) is a no-op, never fatal.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified rendering — markdown core.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for parsing markdown into a `marked` token AST. Every
|
|
5
|
+
* surface renderer (Telegram HTML, ANSI terminal, plain text) consumes these
|
|
6
|
+
* tokens so the SAME agent output renders consistently everywhere.
|
|
7
|
+
*/
|
|
8
|
+
import { type Token, type TokensList } from 'marked';
|
|
9
|
+
/** Normalize then lex markdown into a `marked` token list. */
|
|
10
|
+
export declare function parseMarkdown(md: string): TokensList;
|
|
11
|
+
export type { Token, TokensList };
|
|
12
|
+
/** HTML-escape the 3 characters that matter inside Telegram/HTML text + <pre>. */
|
|
13
|
+
export declare function escapeHtml(s: string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified rendering — markdown core.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for parsing markdown into a `marked` token AST. Every
|
|
5
|
+
* surface renderer (Telegram HTML, ANSI terminal, plain text) consumes these
|
|
6
|
+
* tokens so the SAME agent output renders consistently everywhere.
|
|
7
|
+
*/
|
|
8
|
+
import { marked } from 'marked';
|
|
9
|
+
/** Normalize then lex markdown into a `marked` token list. */
|
|
10
|
+
export function parseMarkdown(md) {
|
|
11
|
+
const normalized = (md ?? '').replace(/\r\n/g, '\n');
|
|
12
|
+
return marked.lexer(normalized);
|
|
13
|
+
}
|
|
14
|
+
/** HTML-escape the 3 characters that matter inside Telegram/HTML text + <pre>. */
|
|
15
|
+
export function escapeHtml(s) {
|
|
16
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=markdown-core.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified rendering — Telegram HTML.
|
|
3
|
+
*
|
|
4
|
+
* Renders a markdown string to Telegram's HTML subset (the robust parse mode:
|
|
5
|
+
* inside text/<pre> only & < > need escaping, vs MarkdownV2's ~18 specials).
|
|
6
|
+
* Output is ALWAYS valid HTML with balanced tags, split into ≤4096-char chunks
|
|
7
|
+
* so a long reply never gets rejected. Unsupported markdown (headings, tables,
|
|
8
|
+
* lists, hr) degrades to <b>/monospace/bullets rather than breaking.
|
|
9
|
+
*
|
|
10
|
+
* Supported Telegram tags: <b> <i> <u> <s> <code> <pre> <a href> <blockquote>.
|
|
11
|
+
*/
|
|
12
|
+
import { marked } from 'marked';
|
|
13
|
+
/**
|
|
14
|
+
* Render markdown → array of Telegram-HTML message chunks (each ≤ maxLen,
|
|
15
|
+
* each independently valid). Never throws: on any parse error it falls back to
|
|
16
|
+
* escaped plain text.
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderTelegramHtml(md: string, maxLen?: number): string[];
|
|
19
|
+
/** Re-export so the lexer used here is the only `marked` entry point. */
|
|
20
|
+
export { marked };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified rendering — Telegram HTML.
|
|
3
|
+
*
|
|
4
|
+
* Renders a markdown string to Telegram's HTML subset (the robust parse mode:
|
|
5
|
+
* inside text/<pre> only & < > need escaping, vs MarkdownV2's ~18 specials).
|
|
6
|
+
* Output is ALWAYS valid HTML with balanced tags, split into ≤4096-char chunks
|
|
7
|
+
* so a long reply never gets rejected. Unsupported markdown (headings, tables,
|
|
8
|
+
* lists, hr) degrades to <b>/monospace/bullets rather than breaking.
|
|
9
|
+
*
|
|
10
|
+
* Supported Telegram tags: <b> <i> <u> <s> <code> <pre> <a href> <blockquote>.
|
|
11
|
+
*/
|
|
12
|
+
import { marked } from 'marked';
|
|
13
|
+
import { parseMarkdown, escapeHtml } from './markdown-core.js';
|
|
14
|
+
const TG_MAX = 4096;
|
|
15
|
+
// --- inline tokens → Telegram HTML ----------------------------------------
|
|
16
|
+
function renderInline(tokens) {
|
|
17
|
+
if (!tokens)
|
|
18
|
+
return '';
|
|
19
|
+
let out = '';
|
|
20
|
+
for (const t of tokens) {
|
|
21
|
+
switch (t.type) {
|
|
22
|
+
case 'text': {
|
|
23
|
+
const tok = t;
|
|
24
|
+
out += tok.tokens ? renderInline(tok.tokens) : escapeHtml(tok.text);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case 'escape':
|
|
28
|
+
out += escapeHtml(t.text);
|
|
29
|
+
break;
|
|
30
|
+
case 'strong':
|
|
31
|
+
out += `<b>${renderInline(t.tokens)}</b>`;
|
|
32
|
+
break;
|
|
33
|
+
case 'em':
|
|
34
|
+
out += `<i>${renderInline(t.tokens)}</i>`;
|
|
35
|
+
break;
|
|
36
|
+
case 'del':
|
|
37
|
+
out += `<s>${renderInline(t.tokens)}</s>`;
|
|
38
|
+
break;
|
|
39
|
+
case 'codespan':
|
|
40
|
+
out += `<code>${escapeHtml(t.text)}</code>`;
|
|
41
|
+
break;
|
|
42
|
+
case 'br':
|
|
43
|
+
out += '\n';
|
|
44
|
+
break;
|
|
45
|
+
case 'link': {
|
|
46
|
+
const lnk = t;
|
|
47
|
+
const inner = renderInline(lnk.tokens) || escapeHtml(lnk.text || '');
|
|
48
|
+
// Only emit a link for safe http(s) hrefs; otherwise keep just the text.
|
|
49
|
+
out += /^https?:\/\//i.test(lnk.href || '')
|
|
50
|
+
? `<a href="${escapeHtml(lnk.href)}">${inner}</a>`
|
|
51
|
+
: inner;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case 'image':
|
|
55
|
+
out += escapeHtml(t.text || t.href || '');
|
|
56
|
+
break;
|
|
57
|
+
case 'html':
|
|
58
|
+
// Raw inline HTML from the model is unsafe for Telegram → escape it literally.
|
|
59
|
+
out += escapeHtml(t.text);
|
|
60
|
+
break;
|
|
61
|
+
default: {
|
|
62
|
+
const any = t;
|
|
63
|
+
out += any.tokens ? renderInline(any.tokens) : escapeHtml(any.text ?? any.raw ?? '');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Inline tokens → plain text (no tags) — used inside <pre> table cells. */
|
|
70
|
+
function inlineToPlain(tokens) {
|
|
71
|
+
if (!tokens)
|
|
72
|
+
return '';
|
|
73
|
+
let out = '';
|
|
74
|
+
for (const t of tokens) {
|
|
75
|
+
const any = t;
|
|
76
|
+
out += any.tokens ? inlineToPlain(any.tokens) : (any.text ?? '');
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Mobile Telegram <pre> wraps past ~40 monospace chars, which destroys column
|
|
81
|
+
* alignment — so only narrow tables get the aligned grid; wider ones become a
|
|
82
|
+
* vertical "record" layout (bold row title + `Header : value` lines). */
|
|
83
|
+
const TABLE_FIT_WIDTH = 40;
|
|
84
|
+
/** A markdown table → a complete Telegram-HTML block (aligned <pre> if it fits
|
|
85
|
+
* the mobile width, otherwise a responsive vertical layout). */
|
|
86
|
+
function renderTableBlock(t) {
|
|
87
|
+
const headers = t.header.map((c) => inlineToPlain(c.tokens).trim());
|
|
88
|
+
const rows = t.rows.map((r) => r.map((c) => inlineToPlain(c.tokens).trim()));
|
|
89
|
+
const cols = headers.length;
|
|
90
|
+
const widths = [];
|
|
91
|
+
for (let i = 0; i < cols; i++) {
|
|
92
|
+
widths[i] = Math.max(headers[i]?.length ?? 0, ...rows.map((r) => (r[i] ?? '').length), 1);
|
|
93
|
+
}
|
|
94
|
+
const totalWidth = widths.reduce((a, b) => a + b, 0) + (cols - 1) * 2;
|
|
95
|
+
const hasNewline = [...headers, ...rows.flat()].some((c) => c.includes('\n'));
|
|
96
|
+
// Narrow + single-line → aligned monospace grid.
|
|
97
|
+
if (totalWidth <= TABLE_FIT_WIDTH && !hasNewline) {
|
|
98
|
+
const fmt = (cells) => cells.map((c, i) => (c ?? '').padEnd(widths[i])).join(' ').trimEnd();
|
|
99
|
+
const sep = widths.map((w) => '─'.repeat(w)).join(' ');
|
|
100
|
+
const text = [fmt(headers), sep, ...rows.map(fmt)].join('\n');
|
|
101
|
+
return `<pre>${escapeHtml(text)}</pre>`;
|
|
102
|
+
}
|
|
103
|
+
// Wide → vertical records: first column is the bold row title, the rest are
|
|
104
|
+
// `Header : value`. Reads cleanly at any screen width (no horizontal wrap).
|
|
105
|
+
return rows
|
|
106
|
+
.map((r) => {
|
|
107
|
+
const lines = [`<b>${escapeHtml(r[0] ?? '')}</b>`];
|
|
108
|
+
for (let i = 1; i < cols; i++) {
|
|
109
|
+
if (r[i])
|
|
110
|
+
lines.push(`${escapeHtml(headers[i] ?? '')} : ${escapeHtml(r[i])}`);
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
})
|
|
114
|
+
.join('\n\n');
|
|
115
|
+
}
|
|
116
|
+
// --- block tokens → array of balanced-HTML fragments -----------------------
|
|
117
|
+
function renderBlocks(tokens) {
|
|
118
|
+
const blocks = [];
|
|
119
|
+
for (const t of tokens) {
|
|
120
|
+
switch (t.type) {
|
|
121
|
+
case 'space':
|
|
122
|
+
break;
|
|
123
|
+
case 'heading':
|
|
124
|
+
blocks.push(`<b>${renderInline(t.tokens)}</b>`);
|
|
125
|
+
break;
|
|
126
|
+
case 'paragraph':
|
|
127
|
+
blocks.push(renderInline(t.tokens));
|
|
128
|
+
break;
|
|
129
|
+
case 'text': {
|
|
130
|
+
const tok = t;
|
|
131
|
+
blocks.push(tok.tokens ? renderInline(tok.tokens) : escapeHtml(tok.text));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'code':
|
|
135
|
+
blocks.push(`<pre>${escapeHtml(t.text)}</pre>`);
|
|
136
|
+
break;
|
|
137
|
+
case 'table':
|
|
138
|
+
blocks.push(renderTableBlock(t));
|
|
139
|
+
break;
|
|
140
|
+
case 'blockquote':
|
|
141
|
+
blocks.push(`<blockquote>${renderBlocks(t.tokens).join('\n')}</blockquote>`);
|
|
142
|
+
break;
|
|
143
|
+
case 'list': {
|
|
144
|
+
const list = t;
|
|
145
|
+
const lines = list.items.map((it, i) => {
|
|
146
|
+
const marker = list.ordered ? `${(Number(list.start) || 1) + i}. ` : '• ';
|
|
147
|
+
const body = renderBlocks(it.tokens).join('\n');
|
|
148
|
+
return marker + body.replace(/\n/g, '\n' + ' '.repeat(marker.length));
|
|
149
|
+
});
|
|
150
|
+
blocks.push(lines.join('\n'));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'hr':
|
|
154
|
+
blocks.push('──────────');
|
|
155
|
+
break;
|
|
156
|
+
case 'html':
|
|
157
|
+
blocks.push(escapeHtml(t.text));
|
|
158
|
+
break;
|
|
159
|
+
default: {
|
|
160
|
+
const any = t;
|
|
161
|
+
blocks.push(any.tokens ? renderInline(any.tokens) : escapeHtml(any.text ?? any.raw ?? ''));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return blocks.filter((b) => b.trim().length > 0);
|
|
166
|
+
}
|
|
167
|
+
/** Split one oversized block (a <pre> is re-fenced per chunk) at line/char bounds. */
|
|
168
|
+
function splitBlock(block, maxLen) {
|
|
169
|
+
const isPre = block.startsWith('<pre>') && block.endsWith('</pre>');
|
|
170
|
+
const inner = isPre ? block.slice(5, -6) : block;
|
|
171
|
+
const wrap = (s) => (isPre ? `<pre>${s}</pre>` : s);
|
|
172
|
+
const budget = maxLen - (isPre ? 11 : 0);
|
|
173
|
+
const out = [];
|
|
174
|
+
let cur = '';
|
|
175
|
+
const flush = () => { if (cur) {
|
|
176
|
+
out.push(wrap(cur));
|
|
177
|
+
cur = '';
|
|
178
|
+
} };
|
|
179
|
+
for (const ln of inner.split('\n')) {
|
|
180
|
+
if (ln.length > budget) {
|
|
181
|
+
flush();
|
|
182
|
+
for (let i = 0; i < ln.length; i += budget)
|
|
183
|
+
out.push(wrap(ln.slice(i, i + budget)));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (cur.length + ln.length + 1 > budget)
|
|
187
|
+
flush();
|
|
188
|
+
cur += (cur ? '\n' : '') + ln;
|
|
189
|
+
}
|
|
190
|
+
flush();
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Render markdown → array of Telegram-HTML message chunks (each ≤ maxLen,
|
|
195
|
+
* each independently valid). Never throws: on any parse error it falls back to
|
|
196
|
+
* escaped plain text.
|
|
197
|
+
*/
|
|
198
|
+
export function renderTelegramHtml(md, maxLen = TG_MAX) {
|
|
199
|
+
let blocks;
|
|
200
|
+
try {
|
|
201
|
+
blocks = renderBlocks(parseMarkdown(md));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
blocks = (md ?? '').split('\n\n').map((b) => escapeHtml(b)).filter((b) => b.trim());
|
|
205
|
+
}
|
|
206
|
+
if (blocks.length === 0)
|
|
207
|
+
return [];
|
|
208
|
+
const chunks = [];
|
|
209
|
+
let cur = '';
|
|
210
|
+
const flush = () => { if (cur) {
|
|
211
|
+
chunks.push(cur);
|
|
212
|
+
cur = '';
|
|
213
|
+
} };
|
|
214
|
+
for (const b of blocks) {
|
|
215
|
+
if (b.length > maxLen) {
|
|
216
|
+
flush();
|
|
217
|
+
for (const sub of splitBlock(b, maxLen))
|
|
218
|
+
chunks.push(sub);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (cur.length + b.length + 2 > maxLen)
|
|
222
|
+
flush();
|
|
223
|
+
cur += (cur ? '\n\n' : '') + b;
|
|
224
|
+
}
|
|
225
|
+
flush();
|
|
226
|
+
return chunks;
|
|
227
|
+
}
|
|
228
|
+
/** Re-export so the lexer used here is the only `marked` entry point. */
|
|
229
|
+
export { marked };
|
|
230
|
+
//# sourceMappingURL=telegram-html.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuetz/code-buddy",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Open-source multi-provider AI coding agent for the terminal, desktop, and HTTP. 15 LLM providers (Grok, Claude, ChatGPT, Gemini, Ollama, LM Studio, …) with ~110 tools, a peer-to-peer fleet, opt-in self-improvement, multi-channel messaging, and a skills system.",
|
|
5
5
|
"author": "Patrice Huetz <patrice.huetz@gmail.com>",
|
|
6
6
|
"repository": {
|