@llblab/pi-telegram 0.17.4 → 0.18.0

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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
@@ -8,11 +8,17 @@ import { unlink } from "node:fs/promises";
8
8
  import { basename, extname } from "node:path";
9
9
 
10
10
  import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
11
+ import { buildTelegramMultipartReplyParameters } from "./replies.ts";
12
+ import {
13
+ getTelegramTargetThreadParams,
14
+ type TelegramTarget,
15
+ } from "./target.ts";
11
16
  import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
12
17
 
13
18
  export interface TelegramVoiceReplyTurnView {
14
19
  chatId: number;
15
20
  replyToMessageId: number;
21
+ target?: TelegramTarget;
16
22
  }
17
23
 
18
24
  export interface TelegramVoiceReplySenderDeps {
@@ -26,7 +32,12 @@ export interface TelegramVoiceReplySenderDeps {
26
32
  stdin?: string;
27
33
  retry?: number;
28
34
  },
29
- ) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
35
+ ) => Promise<{
36
+ stdout: string;
37
+ stderr: string;
38
+ code: number;
39
+ killed: boolean;
40
+ }>;
30
41
  sendMultipart: (
31
42
  method: string,
32
43
  fields: Record<string, string>,
@@ -68,15 +79,14 @@ export interface TelegramVoiceReplySenderPorts<THandler = unknown> {
68
79
  }
69
80
 
70
81
  function buildVoiceReplyParameters(
82
+ chatId: number,
71
83
  replyToPrompt: boolean | undefined,
72
84
  replyToMessageId: number | undefined,
85
+ target?: TelegramTarget,
73
86
  ): string | undefined {
74
87
  if (replyToPrompt === false || replyToMessageId === undefined)
75
88
  return undefined;
76
- return JSON.stringify({
77
- message_id: replyToMessageId,
78
- allow_sending_without_reply: true,
79
- });
89
+ return buildTelegramMultipartReplyParameters(chatId, replyToMessageId, target);
80
90
  }
81
91
 
82
92
  async function ensureTelegramVoiceFileFormat(
@@ -116,7 +126,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
116
126
  deps: TelegramVoiceReplySenderDeps,
117
127
  ports: TelegramVoiceReplySenderPorts<THandler> = {},
118
128
  ) {
119
- async function uploadVoiceFile(
129
+ const uploadVoiceFile = async (
120
130
  turn: TelegramVoiceReplyTurnView,
121
131
  filePath: string,
122
132
  options?: {
@@ -124,13 +134,15 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
124
134
  replyMarkup?: unknown;
125
135
  transcriptText?: string;
126
136
  },
127
- ): Promise<void> {
137
+ ): Promise<void> => {
128
138
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
129
139
  assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
130
140
  await sendVoiceChatAction(deps, turn.chatId);
131
141
  const replyParameters = buildVoiceReplyParameters(
142
+ turn.chatId,
132
143
  options?.replyToPrompt,
133
144
  turn.replyToMessageId,
145
+ turn.target,
134
146
  );
135
147
  await deps.sendMultipart(
136
148
  "sendVoice",
@@ -138,6 +150,13 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
138
150
  chat_id: String(turn.chatId),
139
151
  ...(options?.transcriptText ? { caption: options.transcriptText } : {}),
140
152
  ...(replyParameters ? { reply_parameters: replyParameters } : {}),
153
+ ...(turn.target
154
+ ? Object.fromEntries(
155
+ Object.entries(getTelegramTargetThreadParams(turn.target)).map(
156
+ ([key, value]) => [key, String(value)],
157
+ ),
158
+ )
159
+ : {}),
141
160
  ...(options?.replyMarkup !== undefined && options.replyMarkup !== null
142
161
  ? {
143
162
  reply_markup:
@@ -151,9 +170,9 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
151
170
  voiceFilePath,
152
171
  basename(voiceFilePath),
153
172
  );
154
- }
173
+ };
155
174
 
156
- return async function sendVoiceReply(
175
+ return async (
157
176
  turn: TelegramVoiceReplyTurnView,
158
177
  text: string,
159
178
  options?: {
@@ -162,8 +181,9 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
162
181
  replyToPrompt?: boolean;
163
182
  replyMarkup?: unknown;
164
183
  },
165
- ): Promise<void> {
166
- for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ?? []) {
184
+ ): Promise<void> => {
185
+ for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ??
186
+ []) {
167
187
  try {
168
188
  const filePath = await ports.generateVoiceFile?.(text, {
169
189
  lang: options?.lang,
package/lib/outbound.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type TelegramVoiceReplyItem,
20
20
  } from "./outbound-markup.ts";
21
21
  import { createTelegramVoiceReplySender as createTelegramVoiceReplySenderWithPorts } from "./outbound-voice.ts";
22
+ import type { TelegramTarget } from "./target.ts";
22
23
 
23
24
  const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
24
25
  const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
@@ -102,6 +103,7 @@ export interface TelegramVoiceExecResult {
102
103
  export interface TelegramVoiceReplyTurnView {
103
104
  chatId: number;
104
105
  replyToMessageId: number;
106
+ target?: TelegramTarget;
105
107
  }
106
108
 
107
109
  export interface TelegramVoiceReplySenderDeps {
@@ -207,13 +209,13 @@ export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
207
209
  chatId: number,
208
210
  replyToMessageId: number | undefined,
209
211
  text: string,
210
- options?: { parseMode?: "HTML" },
212
+ options?: { parseMode?: "HTML"; target?: TelegramTarget },
211
213
  ) => Promise<number | undefined>;
212
214
  sendMarkdownReply: (
213
215
  chatId: number,
214
216
  replyToMessageId: number | undefined,
215
217
  markdown: string,
216
- options?: { replyMarkup?: TReplyMarkup },
218
+ options?: { replyMarkup?: TReplyMarkup; target?: TelegramTarget },
217
219
  ) => Promise<number | undefined>;
218
220
  cwd?: string;
219
221
  recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
@@ -867,14 +869,14 @@ export function createTelegramOutboundReplyArtifactSender(
867
869
  deps: TelegramVoiceReplySenderDeps,
868
870
  ) {
869
871
  const sendVoiceReply = createTelegramVoiceReplySender(deps);
870
- return async function sendOutboundReplyArtifacts(
872
+ return async (
871
873
  turn: TelegramVoiceReplyTurnView,
872
874
  plan: Pick<
873
875
  TelegramOutboundReplyPlan,
874
876
  "voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
875
877
  >,
876
878
  options?: { replyToPrompt?: boolean },
877
- ): Promise<void> {
879
+ ): Promise<void> => {
878
880
  // Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
879
881
  const voiceReplies = plan.voiceReplies?.length
880
882
  ? plan.voiceReplies
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Telegram message ownership helpers
3
+ * Zones: telegram routing, multi-instance bus, in-memory coordination
4
+ * Owns live message-id ownership lookup so callbacks/reactions can resolve a Telegram UI surface back to the instance/target that produced it
5
+ */
6
+
7
+ import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
8
+
9
+ export interface TelegramMessageOwnershipRecord {
10
+ chatId: number;
11
+ messageId: number;
12
+ target: TelegramTarget;
13
+ instanceId: string;
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ }
17
+
18
+ export interface TelegramMessageOwnershipStore {
19
+ record: (input: {
20
+ chatId: number;
21
+ messageId: number;
22
+ target?: TelegramTarget;
23
+ instanceId: string;
24
+ now?: number;
25
+ }) => TelegramMessageOwnershipRecord;
26
+ get: (
27
+ chatId: number,
28
+ messageId: number,
29
+ ) => TelegramMessageOwnershipRecord | undefined;
30
+ forget: (chatId: number, messageId: number) => boolean;
31
+ forgetTarget: (target: TelegramTarget) => number;
32
+ prune: (options: {
33
+ now: number;
34
+ maxAgeMs?: number;
35
+ maxRecords?: number;
36
+ }) => number;
37
+ entries: () => TelegramMessageOwnershipRecord[];
38
+ clear: () => void;
39
+ }
40
+
41
+ function getTelegramMessageOwnershipKey(
42
+ chatId: number,
43
+ messageId: number,
44
+ ): string {
45
+ return `${chatId}:${messageId}`;
46
+ }
47
+
48
+ function createTelegramMessageOwnershipRecord(input: {
49
+ chatId: number;
50
+ messageId: number;
51
+ target?: TelegramTarget;
52
+ instanceId: string;
53
+ now: number;
54
+ previous?: TelegramMessageOwnershipRecord;
55
+ }): TelegramMessageOwnershipRecord {
56
+ return {
57
+ chatId: input.chatId,
58
+ messageId: input.messageId,
59
+ target: input.target ?? { chatId: input.chatId },
60
+ instanceId: input.instanceId,
61
+ createdAt: input.previous?.createdAt ?? input.now,
62
+ updatedAt: input.now,
63
+ };
64
+ }
65
+
66
+ export function createTelegramMessageOwnershipStore(): TelegramMessageOwnershipStore {
67
+ const records = new Map<string, TelegramMessageOwnershipRecord>();
68
+ return {
69
+ record: (input) => {
70
+ const key = getTelegramMessageOwnershipKey(input.chatId, input.messageId);
71
+ const now = input.now ?? Date.now();
72
+ const record = createTelegramMessageOwnershipRecord({
73
+ ...input,
74
+ now,
75
+ previous: records.get(key),
76
+ });
77
+ records.set(key, record);
78
+ return record;
79
+ },
80
+ get: (chatId, messageId) =>
81
+ records.get(getTelegramMessageOwnershipKey(chatId, messageId)),
82
+ forget: (chatId, messageId) =>
83
+ records.delete(getTelegramMessageOwnershipKey(chatId, messageId)),
84
+ forgetTarget: (target) => {
85
+ const targetKey = getTelegramTargetKey(target);
86
+ let removed = 0;
87
+ for (const [key, record] of records) {
88
+ if (getTelegramTargetKey(record.target) !== targetKey) continue;
89
+ records.delete(key);
90
+ removed += 1;
91
+ }
92
+ return removed;
93
+ },
94
+ prune: ({ now, maxAgeMs, maxRecords }) => {
95
+ let removed = 0;
96
+ if (maxAgeMs !== undefined) {
97
+ for (const [key, record] of records) {
98
+ if (now - record.updatedAt <= maxAgeMs) continue;
99
+ records.delete(key);
100
+ removed += 1;
101
+ }
102
+ }
103
+ if (maxRecords !== undefined && records.size > maxRecords) {
104
+ const oldest = [...records.entries()].sort(
105
+ (left, right) => left[1].updatedAt - right[1].updatedAt,
106
+ );
107
+ for (const [key] of oldest.slice(0, records.size - maxRecords)) {
108
+ records.delete(key);
109
+ removed += 1;
110
+ }
111
+ }
112
+ return removed;
113
+ },
114
+ entries: () => [...records.values()],
115
+ clear: () => {
116
+ records.clear();
117
+ },
118
+ };
119
+ }
package/lib/pi.ts CHANGED
@@ -33,6 +33,29 @@ export type {
33
33
  SlashCommandInfo,
34
34
  };
35
35
 
36
+ export interface ToolExecutionStartEvent {
37
+ type: "tool_execution_start";
38
+ toolCallId: string;
39
+ toolName: string;
40
+ args: unknown;
41
+ }
42
+
43
+ export interface ToolExecutionUpdateEvent {
44
+ type: "tool_execution_update";
45
+ toolCallId: string;
46
+ toolName: string;
47
+ args: unknown;
48
+ partialResult: unknown;
49
+ }
50
+
51
+ export interface ToolExecutionEndEvent {
52
+ type: "tool_execution_end";
53
+ toolCallId: string;
54
+ toolName: string;
55
+ result: unknown;
56
+ isError: boolean;
57
+ }
58
+
36
59
  export interface PiSettingsManager {
37
60
  reload: () => Promise<void>;
38
61
  flush: () => Promise<void>;
@@ -72,8 +95,8 @@ export function canStartPollingInExtensionContext(ctx: unknown): boolean {
72
95
  export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
73
96
  const mode = getExtensionContextMode(ctx);
74
97
  return mode
75
- ? `Telegram polling is unavailable in π ${mode} mode. Use /telegram-connect from a long-lived π session.`
76
- : "Telegram polling is unavailable in this π run mode.";
98
+ ? `Telegram polling is unavailable in Pi ${mode} mode. Use /telegram-connect from a long-lived Pi session.`
99
+ : "Telegram polling is unavailable in this Pi run mode.";
77
100
  }
78
101
 
79
102
  export type PiSendUserMessageOptions = NonNullable<
@@ -118,7 +141,7 @@ export function createScopedModelPatternPersister(deps: {
118
141
  createSettingsManager: (cwd: string) => PiSettingsManager;
119
142
  clearCachedModelMenuInputs: () => void;
120
143
  }): (patterns: string[], ctx: ExtensionContext) => Promise<void> {
121
- return async function persistScopedModelPatterns(patterns, ctx) {
144
+ return async (patterns, ctx) => {
122
145
  const settingsManager = deps.createSettingsManager(ctx.cwd);
123
146
  settingsManager.setEnabledModels(
124
147
  patterns.length > 0 ? patterns : undefined,