@llblab/pi-telegram 0.19.3 → 0.20.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.
package/lib/locks.ts CHANGED
@@ -12,20 +12,38 @@ import {
12
12
  unlinkSync,
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
- import { homedir } from "node:os";
16
- import { dirname, join, resolve } from "node:path";
15
+ import { dirname } from "node:path";
16
+ import { resolveTelegramLocksPath } from "./paths.ts";
17
17
 
18
18
  export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
19
19
  export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 5_000;
20
+ const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
21
+ const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
20
22
 
21
- function getAgentDir(): string {
22
- return process.env.PI_CODING_AGENT_DIR
23
- ? resolve(process.env.PI_CODING_AGENT_DIR)
24
- : join(homedir(), ".pi", "agent");
23
+ function getLocksPath(): string {
24
+ return resolveTelegramLocksPath();
25
25
  }
26
26
 
27
- function getLocksPath(): string {
28
- return join(getAgentDir(), "locks.json");
27
+ /**
28
+ * Resolve the scoped lock key for the active Telegram profile.
29
+ * Default profile → @llblab/pi-telegram
30
+ * Named profile → @llblab/pi-telegram:<name>
31
+ */
32
+ export function resolveTelegramLockKey(activeProfile?: string): string {
33
+ if (activeProfile) return `${TELEGRAM_LOCK_KEY}:${activeProfile}`;
34
+ return TELEGRAM_LOCK_KEY;
35
+ }
36
+
37
+ export interface TelegramActiveProfileGetter {
38
+ getActiveProfileName: () => string | undefined;
39
+ }
40
+
41
+ export function createTelegramLockKeyResolver(
42
+ activeProfile: TelegramActiveProfileGetter,
43
+ ): () => string {
44
+ return function getTelegramLockKey() {
45
+ return resolveTelegramLockKey(activeProfile.getActiveProfileName());
46
+ };
29
47
  }
30
48
 
31
49
  export interface TelegramLockEntry {
@@ -81,7 +99,7 @@ export interface TelegramLockContextStore<
81
99
  }
82
100
 
83
101
  export interface TelegramLockRuntimeOptions {
84
- key?: string;
102
+ key?: string | (() => string | undefined);
85
103
  locksPath?: string;
86
104
  pid?: number;
87
105
  isProcessAlive?: (pid: number) => boolean;
@@ -104,23 +122,46 @@ export function readLocks(path = getLocksPath()): Record<string, unknown> {
104
122
  }
105
123
  }
106
124
 
125
+ function isRetryableLockWriteError(error: unknown): boolean {
126
+ const code = (error as { code?: unknown })?.code;
127
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES";
128
+ }
129
+
130
+ function sleepSync(ms: number): void {
131
+ const buffer = new SharedArrayBuffer(4);
132
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
133
+ }
134
+
107
135
  export function writeLocks(path: string, locks: Record<string, unknown>): void {
108
136
  mkdirSync(dirname(path), { recursive: true });
109
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
110
- try {
111
- writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, {
112
- encoding: "utf8",
113
- mode: 0o600,
114
- });
115
- renameSync(tempPath, path);
116
- } catch (error) {
137
+ const payload = `${JSON.stringify(locks, null, 2)}\n`;
138
+ let lastError: unknown;
139
+ for (let attempt = 0; attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS; attempt += 1) {
140
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${attempt}.tmp`;
117
141
  try {
118
- unlinkSync(tempPath);
119
- } catch {
120
- /* best effort */
142
+ writeFileSync(tempPath, payload, {
143
+ encoding: "utf8",
144
+ mode: 0o600,
145
+ });
146
+ renameSync(tempPath, path);
147
+ return;
148
+ } catch (error) {
149
+ lastError = error;
150
+ try {
151
+ unlinkSync(tempPath);
152
+ } catch {
153
+ /* best effort */
154
+ }
155
+ if (
156
+ !isRetryableLockWriteError(error) ||
157
+ attempt === TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS - 1
158
+ ) {
159
+ throw error;
160
+ }
161
+ sleepSync(TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS * (attempt + 1));
121
162
  }
122
- throw error;
123
163
  }
164
+ throw lastError;
124
165
  }
125
166
 
126
167
  export function parseTelegramLockEntry(
@@ -238,10 +279,18 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
238
279
  nowMs: getNowMs(),
239
280
  staleHeartbeatMs: options.staleHeartbeatMs,
240
281
  });
241
- const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
282
+ const resolveEffectiveKey = (): string => {
283
+ if (typeof key === "function") return key() || TELEGRAM_LOCK_KEY;
284
+ return key;
285
+ };
286
+ const readLock = () => {
287
+ const effectiveKey = resolveEffectiveKey();
288
+ return parseTelegramLockEntry(readLocks(locksPath)[effectiveKey]);
289
+ };
242
290
  const writeLock = (lock: TelegramLockEntry) => {
291
+ const effectiveKey = resolveEffectiveKey();
243
292
  const locks = readLocks(locksPath);
244
- locks[key] = lock;
293
+ locks[effectiveKey] = lock;
245
294
  writeLocks(locksPath, locks);
246
295
  };
247
296
  return {
@@ -262,7 +311,7 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
262
311
  const state = getLockState(readLock(), pid, isAlive, stateOptions());
263
312
  if (state.kind === "active-here" || state.kind === "stale") {
264
313
  const locks = readLocks(locksPath);
265
- delete locks[key];
314
+ delete locks[resolveEffectiveKey()];
266
315
  writeLocks(locksPath, locks);
267
316
  }
268
317
  return state;
@@ -411,7 +460,11 @@ export function createTelegramLockedPollingRuntime<
411
460
  const owner = snapshotLockContext(ctx);
412
461
  stopOwnershipWatcher();
413
462
  ownershipInterval = setInterval(() => {
414
- if (deps.lock.refresh(owner)) return;
463
+ try {
464
+ if (deps.lock.refresh(owner)) return;
465
+ } catch (error) {
466
+ deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
467
+ }
415
468
  stopAfterOwnershipLoss();
416
469
  }, ownershipCheckMs);
417
470
  ownershipInterval.unref?.();
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Telegram runtime JSONL diagnostics log
2
+ * Telegram diagnostics logs
3
3
  * Zones: telegram diagnostics, filesystem, session observability
4
- * Owns session-local append-only runtime evidence for debugging without becoming routing state
4
+ * Owns bounded JSONL runtime evidence files, previous-log preservation, and profile-aware log paths without becoming routing state
5
5
  */
6
6
 
7
7
  import {
@@ -12,8 +12,13 @@ import {
12
12
  writeFileSync,
13
13
  appendFile,
14
14
  } from "node:fs";
15
- import { homedir } from "node:os";
16
- import { dirname, join, resolve } from "node:path";
15
+ import { dirname } from "node:path";
16
+ import {
17
+ resolveAgentDir,
18
+ resolveTelegramProfileTempFilePath,
19
+ } from "./paths.ts";
20
+
21
+ export type TelegramLogPathInput = string | (() => string);
17
22
 
18
23
  export interface TelegramRuntimeJsonlEvent {
19
24
  at: number;
@@ -23,14 +28,14 @@ export interface TelegramRuntimeJsonlEvent {
23
28
  }
24
29
 
25
30
  export interface TelegramRuntimeJsonlLogOptions {
26
- path?: string;
27
- previousPath?: string;
31
+ path?: TelegramLogPathInput;
32
+ previousPath?: TelegramLogPathInput;
28
33
  maxBytes?: number;
29
34
  getNowMs?: () => number;
30
35
  }
31
36
 
32
37
  export interface TelegramRuntimeJsonlLog {
33
- path: string;
38
+ getPath: () => string;
34
39
  reset: (reason: string, scope?: Record<string, unknown>) => void;
35
40
  resetIfScopeChanged: (
36
41
  scopeKey: string,
@@ -42,21 +47,36 @@ export interface TelegramRuntimeJsonlLog {
42
47
 
43
48
  const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
44
49
 
45
- function getAgentDir(): string {
46
- return process.env.PI_CODING_AGENT_DIR
47
- ? resolve(process.env.PI_CODING_AGENT_DIR)
48
- : join(homedir(), ".pi", "agent");
50
+ export function getTelegramRuntimeLogPath(
51
+ agentDir = resolveAgentDir(),
52
+ profileName?: string,
53
+ ): string {
54
+ return resolveTelegramProfileTempFilePath(
55
+ "logs",
56
+ "jsonl",
57
+ agentDir,
58
+ profileName,
59
+ );
49
60
  }
50
61
 
51
- export function getTelegramRuntimeLogPath(agentDir = getAgentDir()): string {
52
- return join(agentDir, "tmp", "telegram", "logs.jsonl");
62
+ export function getTelegramPreviousRuntimeLogPath(
63
+ agentDir = resolveAgentDir(),
64
+ profileName?: string,
65
+ ): string {
66
+ return resolveTelegramProfileTempFilePath(
67
+ "logs",
68
+ "previous.jsonl",
69
+ agentDir,
70
+ profileName,
71
+ );
53
72
  }
54
73
 
55
74
  function safeJsonLine(value: unknown): string {
56
75
  return JSON.stringify(value, (_key, item) => {
57
76
  if (item instanceof Error) return item.message;
58
77
  if (typeof item === "bigint") return item.toString();
59
- if (typeof item === "function" || typeof item === "symbol") return undefined;
78
+ if (typeof item === "function" || typeof item === "symbol")
79
+ return undefined;
60
80
  return item;
61
81
  });
62
82
  }
@@ -64,27 +84,35 @@ function safeJsonLine(value: unknown): string {
64
84
  export function createTelegramRuntimeJsonlLog(
65
85
  options: TelegramRuntimeJsonlLogOptions = {},
66
86
  ): TelegramRuntimeJsonlLog {
67
- const path = options.path ?? getTelegramRuntimeLogPath();
68
- const previousPath =
69
- options.previousPath ?? path.replace(/\.jsonl$/u, ".previous.jsonl");
87
+ const resolvePath = () =>
88
+ typeof options.path === "function"
89
+ ? options.path()
90
+ : (options.path ?? getTelegramRuntimeLogPath());
91
+ const resolvePreviousPath = () => {
92
+ if (typeof options.previousPath === "function") return options.previousPath();
93
+ if (options.previousPath) return options.previousPath;
94
+ return resolvePath().replace(/\.jsonl$/u, ".previous.jsonl");
95
+ };
70
96
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
71
97
  const getNowMs = options.getNowMs ?? Date.now;
72
- let scopeKey: string | undefined;
98
+ const scopeKeys = new Map<string, string | undefined>();
73
99
  let pending: Promise<void> = Promise.resolve();
74
100
 
75
- const ensureParent = () => {
101
+ const ensureParent = (path: string) => {
76
102
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
77
103
  };
78
104
 
79
- const preserveCurrentLog = () => {
105
+ const preserveCurrentLog = (path: string, previousPath: string) => {
80
106
  if (!existsSync(path)) return;
81
107
  mkdirSync(dirname(previousPath), { recursive: true, mode: 0o700 });
82
108
  copyFileSync(path, previousPath);
83
109
  };
84
110
 
85
111
  const writeReset = (reason: string, scope?: Record<string, unknown>) => {
86
- ensureParent();
87
- preserveCurrentLog();
112
+ const path = resolvePath();
113
+ const previousPath = resolvePreviousPath();
114
+ ensureParent(path);
115
+ preserveCurrentLog(path, previousPath);
88
116
  writeFileSync(
89
117
  path,
90
118
  safeJsonLine({
@@ -102,7 +130,8 @@ export function createTelegramRuntimeJsonlLog(
102
130
  pending = pending
103
131
  .catch(() => undefined)
104
132
  .then(async () => {
105
- ensureParent();
133
+ const path = resolvePath();
134
+ ensureParent(path);
106
135
  if (existsSync(path) && statSync(path).size > maxBytes) {
107
136
  writeReset("max-bytes", { maxBytes });
108
137
  }
@@ -116,9 +145,10 @@ export function createTelegramRuntimeJsonlLog(
116
145
  };
117
146
 
118
147
  return {
119
- path,
148
+ getPath: resolvePath,
120
149
  reset(reason, scope) {
121
- scopeKey = scope ? safeJsonLine(scope) : undefined;
150
+ const path = resolvePath();
151
+ scopeKeys.set(path, scope ? safeJsonLine(scope) : undefined);
122
152
  try {
123
153
  writeReset(reason, scope);
124
154
  } catch {
@@ -126,8 +156,9 @@ export function createTelegramRuntimeJsonlLog(
126
156
  }
127
157
  },
128
158
  resetIfScopeChanged(nextScopeKey, reason, scope) {
129
- if (scopeKey === nextScopeKey) return;
130
- scopeKey = nextScopeKey;
159
+ const path = resolvePath();
160
+ if (scopeKeys.get(path) === nextScopeKey) return;
161
+ scopeKeys.set(path, nextScopeKey);
131
162
  try {
132
163
  writeReset(reason, scope);
133
164
  } catch {
package/lib/media.ts CHANGED
@@ -33,11 +33,36 @@ export interface TelegramRichMessage {
33
33
  blocks?: unknown[];
34
34
  }
35
35
 
36
+ export interface TelegramMessageUser {
37
+ id?: number;
38
+ is_bot?: boolean;
39
+ first_name?: string;
40
+ last_name?: string;
41
+ username?: string;
42
+ }
43
+
44
+ export interface TelegramMessageForwardOrigin {
45
+ type?: string;
46
+ sender_user?: TelegramMessageUser;
47
+ sender_user_name?: string;
48
+ sender_chat?: { title?: string; username?: string; id?: number };
49
+ chat?: { title?: string; username?: string; id?: number };
50
+ author_signature?: string;
51
+ }
52
+
36
53
  export interface TelegramReplyToMessage {
37
54
  message_id?: number;
55
+ from?: TelegramMessageUser;
38
56
  text?: string;
39
57
  caption?: string;
40
58
  rich_message?: TelegramRichMessage;
59
+ photo?: TelegramPhotoSize[];
60
+ document?: TelegramDocument;
61
+ video?: TelegramVideo;
62
+ audio?: TelegramAudio;
63
+ voice?: TelegramVoice;
64
+ animation?: TelegramAnimation;
65
+ sticker?: TelegramSticker;
41
66
  }
42
67
 
43
68
  export interface TelegramSticker {
@@ -46,6 +71,10 @@ export interface TelegramSticker {
46
71
 
47
72
  export interface TelegramMediaMessage {
48
73
  message_id: number;
74
+ from?: TelegramMessageUser;
75
+ forward_origin?: TelegramMessageForwardOrigin;
76
+ forward_from?: TelegramMessageUser;
77
+ forward_sender_name?: string;
49
78
  text?: string;
50
79
  caption?: string;
51
80
  rich_message?: TelegramRichMessage;
@@ -277,6 +306,39 @@ function truncateTelegramReplyContextText(text: string): string {
277
306
  return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
278
307
  }
279
308
 
309
+ function formatTelegramUser(user: TelegramMessageUser | undefined): string | undefined {
310
+ if (!user) return undefined;
311
+ if (user.username) return user.username;
312
+ if (typeof user.id === "number") return String(user.id);
313
+ const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
314
+ return name || undefined;
315
+ }
316
+
317
+ function formatTelegramForwardOriginIdentifier(
318
+ message: TelegramMediaMessage,
319
+ ): string | undefined {
320
+ const origin = message.forward_origin;
321
+ const user = origin?.sender_user ?? message.forward_from;
322
+ if (user?.username) return user.username;
323
+ if (typeof user?.id === "number") return String(user.id);
324
+ const chat = origin?.sender_chat ?? origin?.chat;
325
+ if (chat?.username) return chat.username;
326
+ if (typeof chat?.id === "number") return String(chat.id);
327
+ return origin?.sender_user_name ?? message.forward_sender_name;
328
+ }
329
+
330
+ export function extractTelegramForwardContextText(
331
+ message: TelegramMediaMessage,
332
+ allowedUserId?: number,
333
+ ): string {
334
+ const originUser = message.forward_origin?.sender_user ?? message.forward_from;
335
+ const isOwnerOrigin =
336
+ typeof allowedUserId === "number" && originUser?.id === allowedUserId;
337
+ const origin = formatTelegramForwardOriginIdentifier(message);
338
+ if (!origin || isOwnerOrigin) return "";
339
+ return `from: ${origin}`;
340
+ }
341
+
280
342
  export function extractTelegramReplyContextText(
281
343
  message: TelegramMediaMessage,
282
344
  ): string {
@@ -289,13 +351,44 @@ export function extractTelegramReplyContextText(
289
351
  return quoted ? truncateTelegramReplyContextText(quoted) : "";
290
352
  }
291
353
 
354
+ export function buildTelegramReplyContextBlock(
355
+ message: TelegramMediaMessage,
356
+ replyFiles: Pick<DownloadedTelegramFile, "path">[] = [],
357
+ ): string {
358
+ const from = formatTelegramUser(message.reply_to_message?.from);
359
+ const header = from ? `[reply|from:${from}]` : "[reply]";
360
+ const text = extractTelegramReplyContextText(message);
361
+ const dirs = [...new Set(replyFiles.map((file) => dirname(file.path)))];
362
+ const sameDir = dirs.length === 1;
363
+ const attachmentHeader = sameDir
364
+ ? `[attachments${from ? `|from:${from}` : ""}] ${dirs[0]}`
365
+ : `[attachments${from ? `|from:${from}` : ""}]`;
366
+ const fileLines = sameDir
367
+ ? replyFiles.map((file) => `- /${basename(file.path)}`)
368
+ : replyFiles.map((file) => `- ${file.path}`);
369
+ const replyBlock = text ? `${header} ${text}` : header;
370
+ if (fileLines.length > 0) {
371
+ return `${replyBlock}\n\n${attachmentHeader}\n${fileLines.join("\n")}`;
372
+ }
373
+ if (text) return replyBlock;
374
+ return "";
375
+ }
376
+
292
377
  export function appendTelegramReplyContext(
293
378
  text: string,
294
379
  replyContext: string,
295
380
  ): string {
296
381
  if (!replyContext) return text;
297
- const replyBlock = `[reply] ${replyContext}`;
298
- return text ? `${text}\n\n${replyBlock}` : `_\n\n${replyBlock}`;
382
+ return text ? `${text}\n\n${replyContext}` : `_\n\n${replyContext}`;
383
+ }
384
+
385
+ export function appendTelegramForwardContext(
386
+ text: string,
387
+ forwardContext: string,
388
+ ): string {
389
+ if (!forwardContext) return text;
390
+ const forwardBlock = `[forward|${forwardContext.replace(/:\s+/g, ":")}]`;
391
+ return text ? `\n\n${forwardBlock} ${text}` : `\n\n${forwardBlock}`;
299
392
  }
300
393
 
301
394
  export function extractTelegramMessagePromptText(
@@ -303,7 +396,7 @@ export function extractTelegramMessagePromptText(
303
396
  ): string {
304
397
  return appendTelegramReplyContext(
305
398
  extractTelegramMessageText(message),
306
- extractTelegramReplyContextText(message),
399
+ buildTelegramReplyContextBlock(message),
307
400
  );
308
401
  }
309
402
 
@@ -321,7 +414,7 @@ export function extractTelegramMessagesPromptText(
321
414
  if (!firstMessage) return text;
322
415
  return appendTelegramReplyContext(
323
416
  text,
324
- extractTelegramReplyContextText(firstMessage),
417
+ buildTelegramReplyContextBlock(firstMessage),
325
418
  );
326
419
  }
327
420
 
@@ -192,7 +192,7 @@ export function createTelegramButtonPromptTurn(options: {
192
192
  replyToMessageId: options.replyToMessageId,
193
193
  sourceMessageIds: [options.replyToMessageId],
194
194
  queueOrder: options.queueOrder,
195
- queueLane: "default",
195
+ queueLane: "priority",
196
196
  laneOrder: options.queueOrder,
197
197
  queuedAttachments: [],
198
198
  content: [{ type: "text", text: prompt }],
package/lib/outbound.ts CHANGED
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { mkdir } from "node:fs/promises";
9
- import { homedir } from "node:os";
10
- import { join, resolve } from "node:path";
9
+ import { join } from "node:path";
10
+
11
+ import { resolveTelegramTempDir } from "./paths.ts";
11
12
 
12
13
  import {
13
14
  planTelegramButtonReply,
@@ -66,8 +67,7 @@ export function recordTelegramRuntimeEvent(
66
67
  }
67
68
 
68
69
  export type TelegramOutboundCommandTemplateConfig =
69
- | string
70
- | CommandTemplateObjectConfig;
70
+ string | CommandTemplateObjectConfig;
71
71
  export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
72
72
  type?: string;
73
73
  match?: string | string[];
@@ -448,10 +448,7 @@ function getVoiceReplyTemplateValues(
448
448
  }
449
449
 
450
450
  function getDefaultTelegramVoiceTempDir(): string {
451
- const agentDir = process.env.PI_CODING_AGENT_DIR
452
- ? resolve(process.env.PI_CODING_AGENT_DIR)
453
- : join(homedir(), ".pi", "agent");
454
- return join(agentDir, "tmp", "telegram");
451
+ return resolveTelegramTempDir();
455
452
  }
456
453
 
457
454
  async function generateTelegramVoiceReplyFileWithHandler(
package/lib/paths.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Telegram bridge path resolution for Pi-compatible runtimes
3
+ * Zones: telemetry paths, filesystem, runtime identity
4
+ * Owns agent-dir detection and extension-local path derivation
5
+ *
6
+ * This domain is pure/path-only: it resolves directories and file paths
7
+ * from environment and runtime identity. It does not read config, manage
8
+ * state, or import broader Telegram domains.
9
+ */
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+
13
+ export interface TelegramAgentDirResolutionInput {
14
+ env?: Partial<Pick<NodeJS.ProcessEnv, "PI_CODING_AGENT_DIR">>;
15
+ execPath?: string;
16
+ argv?: readonly string[];
17
+ }
18
+
19
+ /**
20
+ * Resolve the agent data directory for the current Pi-compatible runtime.
21
+ *
22
+ * Precedence:
23
+ * 1. `PI_CODING_AGENT_DIR` env variable, when explicitly set.
24
+ * 2. Detect Pi-compatible runtime identity from the executable or argv[1]
25
+ * (e.g. OMP vs standard Pi agent).
26
+ * 3. Fallback: `~/.pi/agent`.
27
+ */
28
+ export function resolveAgentDir(
29
+ input: TelegramAgentDirResolutionInput = {},
30
+ ): string {
31
+ const env = input.env ?? process.env;
32
+ if (env.PI_CODING_AGENT_DIR) return resolve(env.PI_CODING_AGENT_DIR);
33
+ const execPath = input.execPath ?? process.execPath;
34
+ const argv = input.argv ?? process.argv;
35
+ const execBasename = execPath.toLowerCase().split(/[\\/]/u).pop() ?? "";
36
+ const argv1Last = (argv[1] ?? "").toLowerCase().split(/[\\/]/u).pop() ?? "";
37
+ if (execBasename.startsWith("omp") || argv1Last.startsWith("omp")) {
38
+ return join(homedir(), ".omp", "agent");
39
+ }
40
+ return join(homedir(), ".pi", "agent");
41
+ }
42
+
43
+ /** Telegram bridge configuration file (<agentDir>/telegram.json). */
44
+ export function resolveTelegramConfigPath(): string {
45
+ return join(resolveAgentDir(), "telegram.json");
46
+ }
47
+
48
+ /** Telegram singleton lock file (<agentDir>/locks.json). */
49
+ export function resolveTelegramLocksPath(): string {
50
+ return join(resolveAgentDir(), "locks.json");
51
+ }
52
+
53
+ /** Telegram bridge temporary directory (<agentDir>/tmp/telegram). */
54
+ export function resolveTelegramTempDir(agentDir = resolveAgentDir()): string {
55
+ return join(agentDir, "tmp", "telegram");
56
+ }
57
+
58
+ export function getTelegramProfilePathSuffix(profileName?: string): string {
59
+ return profileName ? `.${profileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}` : "";
60
+ }
61
+
62
+ export function resolveTelegramProfileTempFilePath(
63
+ baseName: string,
64
+ extension: string,
65
+ agentDir = resolveAgentDir(),
66
+ profileName?: string,
67
+ ): string {
68
+ return join(
69
+ resolveTelegramTempDir(agentDir),
70
+ `${baseName}${getTelegramProfilePathSuffix(profileName)}.${extension}`,
71
+ );
72
+ }
73
+
74
+ /** Runtime event log (<agentDir>/tmp/telegram/logs.jsonl). */
75
+ export function resolveTelegramRuntimeLogPath(): string {
76
+ return resolveTelegramProfileTempFilePath("logs", "jsonl");
77
+ }
@@ -116,11 +116,13 @@ export function getTelegramPromptTemplateCommands(
116
116
  if (!telegramCommand) continue;
117
117
  if (reservedNames.has(telegramCommand)) continue;
118
118
  if (seen.has(telegramCommand)) continue;
119
+ const sourcePath = command.sourceInfo?.path;
120
+ if (!sourcePath) continue;
119
121
  seen.add(telegramCommand);
120
122
  promptCommands.push({
121
123
  command: telegramCommand,
122
124
  description: command.description,
123
- path: command.sourceInfo.path,
125
+ path: sourcePath,
124
126
  });
125
127
  }
126
128
  return promptCommands.sort((a, b) => a.command.localeCompare(b.command));
package/lib/prompts.ts CHANGED
@@ -15,7 +15,7 @@ Telegram bridge available. Do not use it from local/TUI prompts unless explicitl
15
15
 
16
16
  const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
17
17
 
18
- Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`.`;
18
+ Telegram turn note: If context was compacted or you need the pi-telegram bridge contract, call tool \`telegram_help\`; hidden comments are valid only for explicit \`telegram_voice\` or \`telegram_button\` actions with payload.`;
19
19
 
20
20
  const TELEGRAM_HELP_TEXT = `--- TELEGRAM BRIDGE HELP ---
21
21
 
package/lib/queue.ts CHANGED
@@ -273,6 +273,37 @@ export function appendTelegramQueueItem<
273
273
  return [...items, item];
274
274
  }
275
275
 
276
+ function getTelegramPromptTextSignature(item: PendingTelegramTurn): string {
277
+ return item.content
278
+ .filter((entry): entry is TelegramPromptTextContent => entry.type === "text")
279
+ .map((entry) => entry.text)
280
+ .join("\n");
281
+ }
282
+
283
+ function isDuplicateTelegramPromptTurn(
284
+ left: PendingTelegramTurn,
285
+ right: PendingTelegramTurn,
286
+ ): boolean {
287
+ return (
288
+ left.chatId === right.chatId &&
289
+ left.target?.threadId === right.target?.threadId &&
290
+ left.replyToMessageId === right.replyToMessageId &&
291
+ getTelegramPromptTextSignature(left) === getTelegramPromptTextSignature(right)
292
+ );
293
+ }
294
+
295
+ export function appendTelegramPromptTurnOnce<TContext = unknown>(
296
+ items: TelegramQueueItem<TContext>[],
297
+ turn: PendingTelegramTurn,
298
+ ): { items: TelegramQueueItem<TContext>[]; appended: boolean } {
299
+ assertTelegramQueueItemAdmissionValid(turn);
300
+ const duplicate = items.some(
301
+ (item) => isPendingTelegramTurn(item) && isDuplicateTelegramPromptTurn(item, turn),
302
+ );
303
+ if (duplicate) return { items, appended: false };
304
+ return { items: [...items, turn], appended: true };
305
+ }
306
+
276
307
  export function compareTelegramQueueItems<TContext = unknown>(
277
308
  left: TelegramQueueItem<TContext>,
278
309
  right: TelegramQueueItem<TContext>,
@@ -2051,7 +2082,7 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
2051
2082
  }
2052
2083
  deps.onPromptDispatchStart(plan.item.chatId);
2053
2084
  try {
2054
- deps.sendUserMessage(plan.item.content, TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY);
2085
+ deps.sendUserMessage(plan.item.content);
2055
2086
  } catch (error) {
2056
2087
  const message = getTelegramQueueErrorMessage(error);
2057
2088
  deps.onPromptDispatchFailure(message);