jeo-code 0.7.48 → 0.7.49
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.
- package/CHANGELOG.md +21 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +15 -1
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +71 -0
- package/src/agent/notify/telegram-daemon.ts +371 -38
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/state.ts +7 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/launch.ts +141 -4
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram HTML formatting helpers (gjc `notifications-sdk` HTML-format-source
|
|
3
|
+
* parity, scoped to jeo's needs). All notify-daemon Telegram output is sent
|
|
4
|
+
* with `parse_mode: "HTML"`. This module is the single source of truth for:
|
|
5
|
+
* escaping dynamic text, converting a bounded markdown subset into Telegram
|
|
6
|
+
* HTML, and safely truncating/splitting a finished message to Telegram's
|
|
7
|
+
* 4096-char limit without breaking tags or entities. `ask`-tool inline-button
|
|
8
|
+
* layout helpers (`buttonLabel`, `buildButtonGrid`, etc.) are OUT OF SCOPE —
|
|
9
|
+
* jeo has no `ask` tool (see Tier 2 contract).
|
|
10
|
+
*
|
|
11
|
+
* Discipline: escape first, tag second. Telegram only parses a small tag set
|
|
12
|
+
* (b, i, u, s, code, pre, a, blockquote, tg-spoiler); a stray `<` or unbalanced
|
|
13
|
+
* tag can make Telegram reject the whole message, so dynamic text is always
|
|
14
|
+
* escaped before any tag is emitted.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Telegram Bot API constants (parse mode + hard per-message length cap).
|
|
18
|
+
export const TELEGRAM_PARSE_MODE = "HTML" as const;
|
|
19
|
+
export const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
20
|
+
|
|
21
|
+
/** Tags Telegram parses in HTML mode (used by the truncation/split guards). */
|
|
22
|
+
const ALLOWED_TAGS: Record<string, true> = { b: true, i: true, u: true, s: true, code: true, pre: true, a: true, blockquote: true, "tg-spoiler": true };
|
|
23
|
+
|
|
24
|
+
/** Escape text for Telegram HTML body content (`& < >`). */
|
|
25
|
+
export function escapeHtml(value: string): string {
|
|
26
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Escape a value for use inside a double-quoted HTML attribute. */
|
|
30
|
+
function escapeAttr(value: string): string {
|
|
31
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap already-escaped text in a Telegram tag. */
|
|
35
|
+
function tag(name: string, escaped: string): string {
|
|
36
|
+
return `<${name}>${escaped}</${name}>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bold the given raw text (escaped internally). */
|
|
40
|
+
export function bold(raw: string): string {
|
|
41
|
+
return tag("b", escapeHtml(raw));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Italicize the given raw text (escaped internally). */
|
|
45
|
+
export function italic(raw: string): string {
|
|
46
|
+
return tag("i", escapeHtml(raw));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render the given raw text as inline code (escaped internally). */
|
|
50
|
+
export function code(raw: string): string {
|
|
51
|
+
return tag("code", escapeHtml(raw));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Render the given raw text as a preformatted block (escaped internally). */
|
|
55
|
+
export function pre(raw: string): string {
|
|
56
|
+
return tag("pre", escapeHtml(raw));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PLACEHOLDER_PREFIX = "\u0000ph";
|
|
60
|
+
const PLACEHOLDER_SUFFIX = "\u0000";
|
|
61
|
+
|
|
62
|
+
/** Only http(s) and mailto links are emitted; anything else stays literal. */
|
|
63
|
+
function isSafeUrl(url: string): boolean {
|
|
64
|
+
return /^(https?:\/\/|mailto:)/i.test(url);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Column alignment parsed from a GFM table separator cell. */
|
|
68
|
+
type ColumnAlign = "left" | "right" | "center";
|
|
69
|
+
|
|
70
|
+
/** Split a markdown table row into trimmed cells, honoring escaped `\|`. */
|
|
71
|
+
function splitTableRow(line: string): string[] {
|
|
72
|
+
let s = line.trim();
|
|
73
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
74
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
75
|
+
const cells: string[] = [];
|
|
76
|
+
let cur = "";
|
|
77
|
+
for (let i = 0; i < s.length; i++) {
|
|
78
|
+
const ch = s[i]!;
|
|
79
|
+
if (ch === "\\" && s[i + 1] === "|") {
|
|
80
|
+
cur += "|";
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === "|") {
|
|
85
|
+
cells.push(cur);
|
|
86
|
+
cur = "";
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
cur += ch;
|
|
90
|
+
}
|
|
91
|
+
cells.push(cur);
|
|
92
|
+
return cells.map(c => c.trim());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A line is a candidate table row when it contains an unescaped `|`. */
|
|
96
|
+
function looksLikeTableRow(line: string): boolean {
|
|
97
|
+
return /(?:^|[^\\])\|/.test(line);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A separator row has only dashes/colons/spaces per cell, with at least one dash. */
|
|
101
|
+
function isTableSeparator(line: string): boolean {
|
|
102
|
+
if (!looksLikeTableRow(line)) return false;
|
|
103
|
+
const cells = splitTableRow(line);
|
|
104
|
+
return cells.length > 0 && cells.every(c => /^:?-+:?$/.test(c));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Derive a column alignment from a separator cell (`:---`, `---:`, `:---:`). */
|
|
108
|
+
function parseAlign(cell: string): ColumnAlign {
|
|
109
|
+
const c = cell.trim();
|
|
110
|
+
const left = c.startsWith(":");
|
|
111
|
+
const right = c.endsWith(":");
|
|
112
|
+
if (left && right) return "center";
|
|
113
|
+
if (right) return "right";
|
|
114
|
+
return "left";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render parsed table parts as an aligned, monospace-friendly plain-text grid. */
|
|
118
|
+
function renderTableText(header: string[], aligns: ColumnAlign[], body: string[][]): string {
|
|
119
|
+
const rows = [header, ...body];
|
|
120
|
+
const cols = Math.max(header.length, ...body.map(r => r.length));
|
|
121
|
+
const widths: number[] = [];
|
|
122
|
+
for (let c = 0; c < cols; c++) {
|
|
123
|
+
let w = 1;
|
|
124
|
+
for (const row of rows) w = Math.max(w, (row[c] ?? "").length);
|
|
125
|
+
widths[c] = w;
|
|
126
|
+
}
|
|
127
|
+
const padCell = (value: string, c: number): string => {
|
|
128
|
+
const width = widths[c]!;
|
|
129
|
+
const pad = width - value.length;
|
|
130
|
+
if (pad <= 0) return value;
|
|
131
|
+
const align = aligns[c] ?? "left";
|
|
132
|
+
if (align === "right") return " ".repeat(pad) + value;
|
|
133
|
+
if (align === "center") {
|
|
134
|
+
const leftPad = Math.floor(pad / 2);
|
|
135
|
+
return " ".repeat(leftPad) + value + " ".repeat(pad - leftPad);
|
|
136
|
+
}
|
|
137
|
+
return value + " ".repeat(pad);
|
|
138
|
+
};
|
|
139
|
+
const renderRow = (row: string[]): string =>
|
|
140
|
+
Array.from({ length: cols }, (_v, c) => padCell(row[c] ?? "", c)).join(" | ");
|
|
141
|
+
const divider = widths.map(w => "-".repeat(w)).join("-|-");
|
|
142
|
+
return [renderRow(header), divider, ...body.map(renderRow)].join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Replace GFM tables with stashed monospace `<pre>` blocks. Telegram HTML has no
|
|
147
|
+
* table primitive, so a header row followed by a `|---|` separator is rendered as
|
|
148
|
+
* an aligned plain-text grid (cell content escaped by `pre`).
|
|
149
|
+
*/
|
|
150
|
+
function convertMarkdownTables(text: string, stash: (html: string) => string): string {
|
|
151
|
+
const lines = text.split("\n");
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
for (let i = 0; i < lines.length; i++) {
|
|
154
|
+
const headerLine = lines[i]!;
|
|
155
|
+
const separatorLine = lines[i + 1];
|
|
156
|
+
if (looksLikeTableRow(headerLine) && separatorLine !== undefined && isTableSeparator(separatorLine)) {
|
|
157
|
+
const header = splitTableRow(headerLine);
|
|
158
|
+
const aligns = splitTableRow(separatorLine).map(parseAlign);
|
|
159
|
+
const body: string[][] = [];
|
|
160
|
+
let j = i + 2;
|
|
161
|
+
for (; j < lines.length; j++) {
|
|
162
|
+
const row = lines[j]!;
|
|
163
|
+
if (!looksLikeTableRow(row) || isTableSeparator(row)) break;
|
|
164
|
+
body.push(splitTableRow(row));
|
|
165
|
+
}
|
|
166
|
+
out.push(stash(pre(renderTableText(header, aligns, body))));
|
|
167
|
+
i = j - 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
out.push(headerLine);
|
|
171
|
+
}
|
|
172
|
+
return out.join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convert a bounded markdown subset into Telegram HTML. Supported: fenced code,
|
|
177
|
+
* GFM tables, inline code, links (`http(s)`/`mailto` only), `#`-headers (bolded,
|
|
178
|
+
* Telegram HTML has no heading tag), `>` blockquotes (merged across consecutive
|
|
179
|
+
* lines), and `**bold**`/`*italic*` emphasis. Everything else passes through
|
|
180
|
+
* escaped, so unsupported/malformed markdown degrades to literal text rather
|
|
181
|
+
* than breaking the message.
|
|
182
|
+
*/
|
|
183
|
+
export function markdownToTelegramHtml(markdown: string): string {
|
|
184
|
+
const placeholders: string[] = [];
|
|
185
|
+
const stash = (html: string): string => {
|
|
186
|
+
const token = `${PLACEHOLDER_PREFIX}${placeholders.length}${PLACEHOLDER_SUFFIX}`;
|
|
187
|
+
placeholders.push(html);
|
|
188
|
+
return token;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
let text = markdown;
|
|
192
|
+
|
|
193
|
+
// 1. Fenced code blocks (protect literal content before any other transform).
|
|
194
|
+
text = text.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body: string) => stash(pre(body)));
|
|
195
|
+
|
|
196
|
+
// 1b. GFM tables -> aligned monospace <pre> block (no native table primitive).
|
|
197
|
+
text = convertMarkdownTables(text, stash);
|
|
198
|
+
|
|
199
|
+
// 2. Inline code.
|
|
200
|
+
text = text.replace(/`([^`\n]+)`/g, (_m, body: string) => stash(code(body)));
|
|
201
|
+
|
|
202
|
+
// 3. Links (capture raw URL before escaping). Unsafe/malformed links stay literal.
|
|
203
|
+
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label: string, url: string) => {
|
|
204
|
+
if (!isSafeUrl(url)) return whole;
|
|
205
|
+
return stash(`<a href="${escapeAttr(url)}">${escapeHtml(label)}</a>`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 4. Escape everything that remains (placeholders contain no escapable chars).
|
|
209
|
+
text = escapeHtml(text);
|
|
210
|
+
|
|
211
|
+
// 5. Line-level transforms on escaped text: headers and merged blockquotes.
|
|
212
|
+
const lines = text.split("\n");
|
|
213
|
+
const out: string[] = [];
|
|
214
|
+
let quoteBuffer: string[] | null = null;
|
|
215
|
+
const flushQuote = () => {
|
|
216
|
+
if (quoteBuffer) {
|
|
217
|
+
out.push(tag("blockquote", quoteBuffer.join("\n")));
|
|
218
|
+
quoteBuffer = null;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
const quote = /^>\s?(.*)$/.exec(line);
|
|
223
|
+
if (quote) {
|
|
224
|
+
if (!quoteBuffer) {
|
|
225
|
+
quoteBuffer = [];
|
|
226
|
+
}
|
|
227
|
+
quoteBuffer.push(quote[1] ?? "");
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
flushQuote();
|
|
231
|
+
const header = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
232
|
+
out.push(header ? tag("b", header[2] ?? "") : line);
|
|
233
|
+
}
|
|
234
|
+
flushQuote();
|
|
235
|
+
text = out.join("\n");
|
|
236
|
+
|
|
237
|
+
// 6. Inline emphasis (bold before italic; unbalanced markers stay literal).
|
|
238
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => tag("b", body));
|
|
239
|
+
text = text.replace(/\*([^*\n]+)\*/g, (_m, body: string) => tag("i", body));
|
|
240
|
+
|
|
241
|
+
// 7. Restore protected placeholders.
|
|
242
|
+
text = text.replace(
|
|
243
|
+
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"),
|
|
244
|
+
(_m, i: string) => placeholders[Number(i)] ?? "",
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return text;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface Token {
|
|
251
|
+
value: string;
|
|
252
|
+
/** Tag name if this token opens a tag, else undefined. */
|
|
253
|
+
open?: string;
|
|
254
|
+
/** Exact opening tag text, including attributes, for safe chunk reopening. */
|
|
255
|
+
openTag?: string;
|
|
256
|
+
/** Tag name if this token closes a tag, else undefined. */
|
|
257
|
+
close?: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Tokenize HTML into tags, entities, and single characters (never splits them). */
|
|
261
|
+
function tokenize(html: string): Token[] {
|
|
262
|
+
const tokens: Token[] = [];
|
|
263
|
+
let i = 0;
|
|
264
|
+
while (i < html.length) {
|
|
265
|
+
const ch = html[i]!;
|
|
266
|
+
if (ch === "<") {
|
|
267
|
+
const end = html.indexOf(">", i);
|
|
268
|
+
if (end !== -1) {
|
|
269
|
+
const raw = html.slice(i, end + 1);
|
|
270
|
+
const close = /^<\/([a-z-]+)>$/i.exec(raw);
|
|
271
|
+
const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
|
|
272
|
+
const token: Token = { value: raw };
|
|
273
|
+
if (close && ALLOWED_TAGS[close[1]!.toLowerCase()]) token.close = close[1]!.toLowerCase();
|
|
274
|
+
else if (openMatch && ALLOWED_TAGS[openMatch[1]!.toLowerCase()]) {
|
|
275
|
+
token.open = openMatch[1]!.toLowerCase();
|
|
276
|
+
token.openTag = raw;
|
|
277
|
+
}
|
|
278
|
+
tokens.push(token);
|
|
279
|
+
i = end + 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (ch === "&") {
|
|
284
|
+
const end = html.indexOf(";", i);
|
|
285
|
+
if (end !== -1 && end - i <= 10) {
|
|
286
|
+
tokens.push({ value: html.slice(i, end + 1) });
|
|
287
|
+
i = end + 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
tokens.push({ value: ch });
|
|
292
|
+
i++;
|
|
293
|
+
}
|
|
294
|
+
return tokens;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Truncate a finished Telegram HTML message to at most `max` chars without
|
|
299
|
+
* splitting a tag or entity, closing any still-open allowed tags and appending
|
|
300
|
+
* `marker`. The final string is guaranteed to be <= `max`.
|
|
301
|
+
*/
|
|
302
|
+
export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT, marker = "… [truncated]"): string {
|
|
303
|
+
if (message.length <= max) return message;
|
|
304
|
+
|
|
305
|
+
// When `max` is too small to even hold the marker, drop it so the hard
|
|
306
|
+
// length guarantee (output.length <= max) still holds.
|
|
307
|
+
const effectiveMarker = marker.length <= max ? marker : "";
|
|
308
|
+
|
|
309
|
+
const tokens = tokenize(message);
|
|
310
|
+
const stack: string[] = [];
|
|
311
|
+
let out = "";
|
|
312
|
+
|
|
313
|
+
const closersFor = (s: string[]): string =>
|
|
314
|
+
s
|
|
315
|
+
.map(t => `</${t}>`)
|
|
316
|
+
.reverse()
|
|
317
|
+
.join("");
|
|
318
|
+
|
|
319
|
+
for (const token of tokens) {
|
|
320
|
+
// Simulate accepting this token, then ensure we can still close + mark.
|
|
321
|
+
const nextStack = [...stack];
|
|
322
|
+
if (token.open) nextStack.push(token.open);
|
|
323
|
+
else if (token.close) {
|
|
324
|
+
const idx = nextStack.lastIndexOf(token.close);
|
|
325
|
+
if (idx !== -1) nextStack.splice(idx, 1);
|
|
326
|
+
}
|
|
327
|
+
const projected = out.length + token.value.length + closersFor(nextStack).length + effectiveMarker.length;
|
|
328
|
+
if (projected > max) break;
|
|
329
|
+
out += token.value;
|
|
330
|
+
if (token.open) stack.push(token.open);
|
|
331
|
+
else if (token.close) {
|
|
332
|
+
const idx = stack.lastIndexOf(token.close);
|
|
333
|
+
if (idx !== -1) stack.splice(idx, 1);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return out + closersFor(stack) + effectiveMarker;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface OpenTag {
|
|
341
|
+
name: string;
|
|
342
|
+
tag: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Split a finished Telegram HTML message into ordered chunks without splitting tags or entities. */
|
|
346
|
+
export function splitTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
|
|
347
|
+
if (message.length <= max) return [message];
|
|
348
|
+
const chunks: string[] = [];
|
|
349
|
+
const tokens = tokenize(message);
|
|
350
|
+
const stack: OpenTag[] = [];
|
|
351
|
+
let out = "";
|
|
352
|
+
let chunkHasBody = false;
|
|
353
|
+
|
|
354
|
+
const closersFor = (s: OpenTag[]): string =>
|
|
355
|
+
s
|
|
356
|
+
.map(t => `</${t.name}>`)
|
|
357
|
+
.reverse()
|
|
358
|
+
.join("");
|
|
359
|
+
const openersFor = (s: OpenTag[]): string => s.map(t => t.tag).join("");
|
|
360
|
+
const minimumChunkLengthFor = (s: OpenTag[]): number => openersFor(s).length + closersFor(s).length + 1;
|
|
361
|
+
const flush = (): void => {
|
|
362
|
+
if (!chunkHasBody) return;
|
|
363
|
+
chunks.push(out + closersFor(stack));
|
|
364
|
+
out = openersFor(stack);
|
|
365
|
+
chunkHasBody = false;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const updateStackForClose = (name: string): boolean => {
|
|
369
|
+
const idx = stack.findLastIndex(t => t.name === name);
|
|
370
|
+
if (idx === -1) return false;
|
|
371
|
+
stack.splice(idx, 1);
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
for (const token of tokens) {
|
|
376
|
+
if (token.open) {
|
|
377
|
+
const nextStack = [...stack, { name: token.open, tag: token.openTag ?? token.value }];
|
|
378
|
+
if (minimumChunkLengthFor(nextStack) > max) continue;
|
|
379
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
|
|
380
|
+
if (out.length + token.value.length + closersFor(nextStack).length > max) continue;
|
|
381
|
+
out += token.value;
|
|
382
|
+
chunkHasBody = true;
|
|
383
|
+
stack.push({ name: token.open, tag: token.openTag ?? token.value });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (token.close) {
|
|
388
|
+
const idx = stack.findLastIndex(t => t.name === token.close);
|
|
389
|
+
if (idx === -1) continue;
|
|
390
|
+
const nextStack = stack.toSpliced(idx, 1);
|
|
391
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
|
|
392
|
+
if (out.length + token.value.length + closersFor(nextStack).length > max) {
|
|
393
|
+
updateStackForClose(token.close);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
out += token.value;
|
|
397
|
+
chunkHasBody = true;
|
|
398
|
+
updateStackForClose(token.close);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(stack).length > max) flush();
|
|
403
|
+
out += token.value;
|
|
404
|
+
chunkHasBody = true;
|
|
405
|
+
}
|
|
406
|
+
if (chunkHasBody) chunks.push(out + closersFor(stack));
|
|
407
|
+
return chunks;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Finalize an optional message: undefined passthrough, else safe-truncate. */
|
|
411
|
+
export function finalizeTelegramHtml(message?: string): string | undefined {
|
|
412
|
+
if (message === undefined) return undefined;
|
|
413
|
+
return truncateTelegramHtml(message);
|
|
414
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session forum-topic registry for the threaded session surface (gjc
|
|
3
|
+
* `topic-registry.ts` parity, adapted: numeric topicId, no identitySent
|
|
4
|
+
* tracking).
|
|
5
|
+
*
|
|
6
|
+
* Each jeo session owns one active Telegram forum topic in the paired private
|
|
7
|
+
* DM. The topic is created via `createForumTopic`, reused while the session
|
|
8
|
+
* remains active, and removed from the registry when the daemon deletes it on
|
|
9
|
+
* shutdown. Unlike gjc, this registry does NOT track whether the one-time
|
|
10
|
+
* identity header has been sent — that is a session-side decision (the
|
|
11
|
+
* session-endpoint owns a simple boolean for "have I sent my own identity
|
|
12
|
+
* this run") and does not need to survive daemon restarts.
|
|
13
|
+
*
|
|
14
|
+
* State is a plain serializable map persisted beside the daemon state files;
|
|
15
|
+
* topic creation is injected so this module is pure and unit-testable without
|
|
16
|
+
* a live Bot API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Persisted record for one session's topic. */
|
|
20
|
+
export interface TopicRecord {
|
|
21
|
+
/** Telegram forum topic id (message_thread_id). */
|
|
22
|
+
topicId: number;
|
|
23
|
+
/** Creation timestamp (ms epoch). */
|
|
24
|
+
createdAt: number;
|
|
25
|
+
/** Last applied topic title (for rename detection). */
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Serializable shape persisted to disk. */
|
|
30
|
+
export interface TopicRegistryState {
|
|
31
|
+
/** sessionId -> record. */
|
|
32
|
+
topics: Record<string, TopicRecord>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function emptyTopicRegistryState(): TopicRegistryState {
|
|
36
|
+
return { topics: {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* In-memory registry over a serializable state. Topic creation is injected via
|
|
41
|
+
* `getOrCreateTopic`'s `create` callback (the daemon supplies a real
|
|
42
|
+
* `createForumTopic` call); reuse-on-resume is automatic when a record exists.
|
|
43
|
+
*/
|
|
44
|
+
export class TopicRegistry {
|
|
45
|
+
private readonly topics: Map<string, TopicRecord>;
|
|
46
|
+
/** Maps topicId -> sessionId for fast inbound routing. */
|
|
47
|
+
private readonly byTopic = new Map<number, string>();
|
|
48
|
+
/** In-flight create promises, keyed by session, to dedupe concurrent creates. */
|
|
49
|
+
private readonly inflight = new Map<string, Promise<TopicRecord>>();
|
|
50
|
+
|
|
51
|
+
constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
|
|
52
|
+
this.topics = new Map(Object.entries(state.topics ?? {}));
|
|
53
|
+
for (const [sessionId, record] of this.topics) this.byTopic.set(record.topicId, sessionId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Merge a serialized state into this registry, preserving all persisted fields. */
|
|
57
|
+
load(state: TopicRegistryState): void {
|
|
58
|
+
for (const [sessionId, record] of Object.entries(state.topics ?? {})) {
|
|
59
|
+
this.topics.set(sessionId, record);
|
|
60
|
+
this.byTopic.set(record.topicId, sessionId);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the owning session for a topic id (for fail-closed inbound routing). */
|
|
65
|
+
sessionForTopic(topicId: number): string | undefined {
|
|
66
|
+
return this.byTopic.get(topicId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** All session ids with a persisted topic record. */
|
|
70
|
+
sessionIds(): string[] {
|
|
71
|
+
return [...this.topics.keys()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The existing topic record for a session, if any. */
|
|
75
|
+
get(sessionId: string): TopicRecord | undefined {
|
|
76
|
+
return this.topics.get(sessionId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return the existing active topic for `sessionId`, or create one via
|
|
81
|
+
* `create` (called only on first use).
|
|
82
|
+
*/
|
|
83
|
+
async getOrCreateTopic(
|
|
84
|
+
sessionId: string,
|
|
85
|
+
create: () => Promise<number>,
|
|
86
|
+
now: () => number = Date.now,
|
|
87
|
+
name?: string,
|
|
88
|
+
): Promise<TopicRecord> {
|
|
89
|
+
const existing = this.topics.get(sessionId);
|
|
90
|
+
if (existing) return existing;
|
|
91
|
+
// Concurrency guard: many session frames (identity/idle/turn/ask) can race
|
|
92
|
+
// to first-use the same session. Without this, each call passes the
|
|
93
|
+
// `existing` check before `create()` resolves and creates a DUPLICATE
|
|
94
|
+
// forum topic. Share a single in-flight create per session id.
|
|
95
|
+
const pending = this.inflight.get(sessionId);
|
|
96
|
+
if (pending) return pending;
|
|
97
|
+
const promise = (async () => {
|
|
98
|
+
const topicId = await create();
|
|
99
|
+
const record: TopicRecord = { topicId, name, createdAt: now() };
|
|
100
|
+
this.topics.set(sessionId, record);
|
|
101
|
+
this.byTopic.set(topicId, sessionId);
|
|
102
|
+
return record;
|
|
103
|
+
})();
|
|
104
|
+
this.inflight.set(sessionId, promise);
|
|
105
|
+
try {
|
|
106
|
+
return await promise;
|
|
107
|
+
} finally {
|
|
108
|
+
this.inflight.delete(sessionId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Record the topic's applied title. Returns `true` when it changed (so the
|
|
114
|
+
* caller should `editForumTopic`), `false` when already current or unknown.
|
|
115
|
+
*/
|
|
116
|
+
applyName(sessionId: string, name: string): boolean {
|
|
117
|
+
const record = this.topics.get(sessionId);
|
|
118
|
+
if (!record || record.name === name) return false;
|
|
119
|
+
record.name = name;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Remove a session topic record after Telegram deletes the topic. */
|
|
124
|
+
delete(sessionId: string): boolean {
|
|
125
|
+
const record = this.topics.get(sessionId);
|
|
126
|
+
if (!record) return false;
|
|
127
|
+
this.topics.delete(sessionId);
|
|
128
|
+
this.byTopic.delete(record.topicId);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Serialize for atomic persistence beside the daemon state. */
|
|
133
|
+
serialize(): TopicRegistryState {
|
|
134
|
+
return { topics: Object.fromEntries(this.topics) };
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/agent/state.ts
CHANGED
|
@@ -61,11 +61,18 @@ export interface Config {
|
|
|
61
61
|
/** Remote subagent visibility/control over Telegram (see `src/agent/notify/`). */
|
|
62
62
|
notifications?: {
|
|
63
63
|
enabled?: boolean;
|
|
64
|
+
/** Session-local default; see `config-schema.ts` for the mutability contract. */
|
|
65
|
+
verbosity?: "lean" | "verbose";
|
|
66
|
+
/** Session-local default; see `config-schema.ts` for the mutability contract. */
|
|
67
|
+
redact?: boolean;
|
|
64
68
|
telegram?: {
|
|
65
69
|
botToken?: string;
|
|
66
70
|
chatId?: string;
|
|
67
71
|
/** Forum-topic thread id (message_thread_id) for daemon pushes. */
|
|
68
72
|
topicId?: number;
|
|
73
|
+
/** Auto-create/manage one forum topic per interactive session instead of
|
|
74
|
+
* the flat/global `topicId` above. See `TopicRegistry`. */
|
|
75
|
+
perSessionTopics?: boolean;
|
|
69
76
|
};
|
|
70
77
|
};
|
|
71
78
|
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "./subagents";
|
|
30
30
|
import { resolveMaxOutputTokens } from "../ai/model-manager";
|
|
31
31
|
import type { SubagentRegistry } from "./subagent-registry";
|
|
32
|
-
import { ensureSessionNotifyEndpoint } from "./notify/session-endpoint";
|
|
32
|
+
import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
/** Lifecycle event emitted while a delegated subagent runs. */
|
|
@@ -70,6 +70,10 @@ export interface TaskToolOptions {
|
|
|
70
70
|
/** When present, a `task` call with `detached: true` registers a background run
|
|
71
71
|
* here and returns immediately; the parent controls it via the `subagent` tool. */
|
|
72
72
|
registry?: SubagentRegistry;
|
|
73
|
+
/** The interactive session's own `SessionNotifyEndpoint`, when running inside
|
|
74
|
+
* one — a detached launch attaches `registry` to THIS instead of starting a
|
|
75
|
+
* second, competing endpoint (see `ensureSessionNotifyEndpoint`). */
|
|
76
|
+
sessionEndpoint?: SessionNotifyEndpoint;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
/** Max concurrent subagents in a fan-out batch (both read-only AND the mutating
|
|
@@ -485,7 +489,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
485
489
|
);
|
|
486
490
|
// Remote subagent visibility/control over Telegram (gjc daemon parity, see
|
|
487
491
|
// `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
|
|
488
|
-
ensureSessionNotifyEndpoint(opts.registry, cwd);
|
|
492
|
+
ensureSessionNotifyEndpoint(opts.registry, cwd, opts.sessionEndpoint);
|
|
489
493
|
|
|
490
494
|
return {
|
|
491
495
|
success: true,
|