@llblab/pi-telegram 0.11.1 → 0.11.2

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/index.ts CHANGED
@@ -11,7 +11,6 @@ import * as Config from "./lib/config.ts";
11
11
  import {
12
12
  createTelegramExtensionSectionRegistry,
13
13
  setGlobalTelegramSectionRegistry,
14
- registerTelegramSection,
15
14
  type TelegramSectionRegistry,
16
15
  } from "./lib/extension-sections.ts";
17
16
  import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
@@ -46,42 +45,6 @@ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
46
45
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
47
46
  type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
48
47
 
49
- export {
50
- registerTelegramOutboundHandler,
51
- hasTelegramOutboundHandler,
52
- getTelegramOutboundProgrammaticHandlers,
53
- recordTelegramRuntimeEvent,
54
- } from "./lib/outbound-handlers.ts";
55
-
56
- // --- Voice Integration Exports ---
57
- // Prefer domain imports from ./lib/voice.ts; root exports stay for compatibility.
58
- export {
59
- registerTelegramVoiceSynthesisProvider,
60
- getTelegramVoiceSynthesisProviders,
61
- hasTelegramVoiceSynthesisProvider,
62
- clearTelegramVoiceSynthesisProviders,
63
- planTelegramVoiceReply,
64
- getTelegramVoiceReplyMode,
65
- computeVoiceTurnFlags,
66
- isVoiceTurn,
67
- shouldSuppressPreviewForVoice,
68
- computeVoicePromptContribution,
69
- type TelegramVoiceSynthesisProvider,
70
- type TelegramVoiceTurnView,
71
- type TelegramVoiceSynthesisProviderResult,
72
- type TelegramVoiceReplyMode,
73
- } from "./lib/voice.ts";
74
-
75
- // --- Extension Section Exports ---
76
- export {
77
- registerTelegramSection,
78
- type TelegramSectionRegistration,
79
- type TelegramSectionContext,
80
- type TelegramSectionCallbackContext,
81
- type TelegramSectionView,
82
- type TelegramSectionSettingsRegistration,
83
- } from "./lib/extension-sections.ts";
84
-
85
48
  // --- Extension Runtime ---
86
49
 
87
50
  export default function (pi: Pi.ExtensionAPI) {
@@ -99,7 +62,10 @@ export default function (pi: Pi.ExtensionAPI) {
99
62
  Config.setGlobalTelegramConfigRuntime({
100
63
  updateVoiceConfig(voice) {
101
64
  const current = configStore.get();
102
- const next = { ...current, voice: { ...(current.voice ?? {}), ...voice } };
65
+ const next = {
66
+ ...current,
67
+ voice: { ...(current.voice ?? {}), ...voice },
68
+ };
103
69
  configStore.set(next);
104
70
  void configStore.persist(next);
105
71
  },
@@ -137,7 +103,6 @@ export default function (pi: Pi.ExtensionAPI) {
137
103
  createTelegramExtensionSectionRegistry();
138
104
  setGlobalTelegramSectionRegistry(sectionRegistry);
139
105
 
140
-
141
106
  const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
142
107
  getBotToken: configStore.getBotToken,
143
108
  });
@@ -146,9 +111,8 @@ export default function (pi: Pi.ExtensionAPI) {
146
111
  getConfig: Config.createTelegramTimeConfigGetter(configStore),
147
112
  recordRuntimeEvent,
148
113
  });
149
- (globalThis as Record<string, unknown>)[
150
- VOICE_EVENT_RECORDER_KEY
151
- ] = recordRuntimeEvent;
114
+ (globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] =
115
+ recordRuntimeEvent;
152
116
  const getContextModel = Pi.getExtensionContextModel;
153
117
  const isIdle = Pi.isExtensionContextIdle;
154
118
  const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
@@ -165,6 +129,7 @@ export default function (pi: Pi.ExtensionAPI) {
165
129
  Queue.createTelegramQueueStore<Pi.ExtensionContext>();
166
130
  const deferredQueueDispatchRuntime =
167
131
  Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
132
+ delayMs: 50,
168
133
  recordRuntimeEvent,
169
134
  });
170
135
  const pollingControllerState = Polling.createTelegramPollingControllerState();
@@ -623,9 +588,23 @@ export default function (pi: Pi.ExtensionAPI) {
623
588
  const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
624
589
  agentLifecycleHooks.onAgentStart,
625
590
  );
591
+ const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
592
+ setCompactionInProgress: lifecycle.setCompactionInProgress,
593
+ updateStatus,
594
+ requestDeferredDispatchNextQueuedTelegramTurn:
595
+ deferredQueueDispatchRuntime.request,
596
+ dispatchNextQueuedTelegramTurn,
597
+ recordRuntimeEvent,
598
+ });
626
599
  Lifecycle.registerTelegramLifecycleHooks(pi, {
627
600
  ...sessionLifecycleRuntime,
628
601
  ...agentLifecycleHooks,
602
+ async onSessionShutdown(event, ctx) {
603
+ compactionObserver.onSessionShutdown();
604
+ await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
605
+ },
606
+ onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
607
+ onSessionCompact: compactionObserver.onSessionCompact,
629
608
  onAgentStart: agentStartWithDedupReset,
630
609
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
631
610
  isProactivePushEnabled,
@@ -8,9 +8,8 @@ import { spawn } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
9
  import { isAbsolute, resolve } from "node:path";
10
10
 
11
- export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
12
-
13
11
  export type CommandTemplateMode = "sequence" | "parallel";
12
+ export type CommandTemplateFailureScope = "continue" | "branch" | "root";
14
13
 
15
14
  export interface CommandTemplateObjectConfig {
16
15
  label?: string;
@@ -23,7 +22,9 @@ export interface CommandTemplateObjectConfig {
23
22
  output?: string;
24
23
  retry?: number;
25
24
  critical?: boolean;
26
- repeat?: number;
25
+ failure?: CommandTemplateFailureScope;
26
+ recover?: CommandTemplateValue;
27
+ repeat?: number | string;
27
28
  }
28
29
 
29
30
  export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
@@ -72,23 +73,92 @@ export function normalizeCommandTemplateConfig(
72
73
  return typeof config === "string" ? { template: config } : config;
73
74
  }
74
75
 
76
+ function normalizeRecoverConfig(
77
+ config: CommandTemplateValue | undefined,
78
+ ): CommandTemplateConfig | undefined {
79
+ if (config === undefined) return undefined;
80
+ return Array.isArray(config) ? { template: config } : config;
81
+ }
82
+
75
83
  function normalizeCommandTemplateDefaults(
76
84
  defaults: Record<string, unknown> | undefined,
77
85
  ): Record<string, unknown> | undefined {
78
86
  if (!defaults) return undefined;
79
87
  const normalized: Record<string, unknown> = {};
80
88
  for (const [key, value] of Object.entries(defaults)) {
81
- normalized[key] =
82
- value === undefined || value === null ? "" : String(value);
89
+ normalized[key] = Array.isArray(value)
90
+ ? value
91
+ : value === undefined || value === null ? "" : String(value);
83
92
  }
84
93
  return normalized;
85
94
  }
86
95
 
87
- function normalizeRepeat(value: number | undefined): number | undefined {
96
+ export function resolveCommandTemplateRepeat(
97
+ value: number | string | undefined,
98
+ values: Record<string, unknown> = {},
99
+ ): number | undefined {
88
100
  if (value === undefined) return undefined;
89
- if (!Number.isInteger(value) || value < 1)
90
- throw new Error("Command template repeat must be a positive integer.");
91
- return value;
101
+ if (typeof value === "number") {
102
+ if (!Number.isInteger(value) || value < 1)
103
+ throw new Error("Command template repeat must be a positive integer.");
104
+ return value;
105
+ }
106
+ const trimmed = value.trim();
107
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
108
+ const lengthMatch = trimmed.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/);
109
+ if (lengthMatch) {
110
+ const source = values[lengthMatch[1]];
111
+ if (Array.isArray(source)) return source.length;
112
+ if (source === undefined) return undefined;
113
+ }
114
+ throw new Error("Command template repeat must be a positive integer or {array.length}.");
115
+ }
116
+
117
+ function getExecutableName(command: string | undefined): string {
118
+ if (!command) return "";
119
+ return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
120
+ }
121
+
122
+ function hasAnyFlag(args: string[], flags: string[]): boolean {
123
+ return args.some((arg) => flags.includes(arg));
124
+ }
125
+
126
+ function hasRiskyPathArg(args: string[]): boolean {
127
+ return args.some((arg) =>
128
+ arg === "/" ||
129
+ arg === "~" ||
130
+ arg === "./" ||
131
+ arg === "../" ||
132
+ arg.includes("{") ||
133
+ arg.startsWith("~/") ||
134
+ arg.startsWith("/"),
135
+ );
136
+ }
137
+
138
+ function getLeafCommandTemplateWarnings(
139
+ config: CommandTemplateLeafConfig,
140
+ ): string[] {
141
+ const parts = splitCommandTemplate(config.template);
142
+ const command = getExecutableName(parts[0]);
143
+ const args = parts.slice(1);
144
+ const warnings: string[] = [];
145
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
146
+ const mode = hasAnyFlag(args, ["-c"]) ? "shell command strings" : "shell scripts";
147
+ warnings.push(`${config.label ?? command}: invokes ${command}; ${mode} are trusted executable content and are not sandboxed by command-template argv splitting.`);
148
+ }
149
+ if (["node", "deno", "bun"].includes(command) && hasAnyFlag(args, ["-e", "--eval"])) {
150
+ warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`);
151
+ }
152
+ if (["python", "python3", "perl", "ruby"].includes(command) && hasAnyFlag(args, ["-c", "-e"])) {
153
+ warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`);
154
+ }
155
+ if (command === "rm" && (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) || hasRiskyPathArg(args))) {
156
+ warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`);
157
+ }
158
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
159
+ warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`);
160
+ }
161
+ return warnings;
92
162
  }
93
163
 
94
164
  function pad(value: number, width: number): string {
@@ -124,7 +194,7 @@ function expandRepeatConfig(
124
194
  config: CommandTemplateObjectConfig,
125
195
  context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
126
196
  ): CommandTemplateObjectConfig[] | undefined {
127
- const repeat = normalizeRepeat(config.repeat);
197
+ const repeat = resolveCommandTemplateRepeat(config.repeat, context.defaults ?? {});
128
198
  if (repeat === undefined) return undefined;
129
199
  return Array.from({ length: repeat }, (_unused, index0) => {
130
200
  const { repeat: _repeat, ...rest } = config;
@@ -164,12 +234,19 @@ export function expandCommandTemplateConfigs(
164
234
  if (repeated) {
165
235
  return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
166
236
  }
237
+ const recoverConfig = normalizeRecoverConfig(normalizedConfig.recover);
238
+ const recoverSteps = recoverConfig
239
+ ? expandCommandTemplateConfigs(recoverConfig, context)
240
+ : [];
167
241
  if (Array.isArray(normalizedConfig.template)) {
168
- return normalizedConfig.template.flatMap((step) =>
169
- expandCommandTemplateConfigs(step, context),
170
- );
242
+ return [
243
+ ...normalizedConfig.template.flatMap((step) =>
244
+ expandCommandTemplateConfigs(step, context),
245
+ ),
246
+ ...recoverSteps,
247
+ ];
171
248
  }
172
- if (typeof normalizedConfig.template !== "string") return [];
249
+ if (typeof normalizedConfig.template !== "string") return recoverSteps;
173
250
  return [
174
251
  {
175
252
  ...normalizedConfig,
@@ -178,9 +255,37 @@ export function expandCommandTemplateConfigs(
178
255
  retry: normalizedConfig.retry,
179
256
  critical: normalizedConfig.critical,
180
257
  },
258
+ ...recoverSteps,
181
259
  ];
182
260
  }
183
261
 
262
+ export function getCommandTemplateWarnings(
263
+ config: CommandTemplateConfig,
264
+ ): string[] {
265
+ return [
266
+ ...new Set(
267
+ expandCommandTemplateConfigs(config)
268
+ .flatMap((leaf) => getLeafCommandTemplateWarnings(leaf)),
269
+ ),
270
+ ];
271
+ }
272
+
273
+ function parseCommandTemplateArgToken(value: string): { name: string; defaultValue?: string } {
274
+ const separatorIndex = value.indexOf("=");
275
+ const rawName = separatorIndex === -1 ? value : value.slice(0, separatorIndex);
276
+ const colonIndex = rawName.indexOf(":");
277
+ return {
278
+ name: (colonIndex === -1 ? rawName : rawName.slice(0, colonIndex)).trim(),
279
+ ...(separatorIndex === -1 ? {} : { defaultValue: value.slice(separatorIndex + 1).trim() }),
280
+ };
281
+ }
282
+
283
+ function parseCommandTemplatePlaceholderContent(content: string): { name: string; inlineDefault?: string } | undefined {
284
+ const match = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?::(?:string|path|int|number|bool|array|enum\([^)]*\)))?(?:=([^}]*))?$/);
285
+ if (!match) return undefined;
286
+ return { name: match[1], ...(match[2] !== undefined ? { inlineDefault: match[2] } : {}) };
287
+ }
288
+
184
289
  export function getCommandTemplateDefaults(
185
290
  config: CommandTemplateConfig | undefined,
186
291
  ): Record<string, string> {
@@ -190,9 +295,9 @@ export function getCommandTemplateDefaults(
190
295
  const defaults: Record<string, string> = {};
191
296
  for (const item of normalizeCommandTemplateArgs(normalizedConfig?.args)) {
192
297
  if (!item) continue;
193
- const [name, ...defaultParts] = item.split("=");
194
- if (!name || defaultParts.length === 0) continue;
195
- defaults[name.trim()] = defaultParts.join("=").trim();
298
+ const parsed = parseCommandTemplateArgToken(item);
299
+ if (!parsed.name || parsed.defaultValue === undefined) continue;
300
+ defaults[parsed.name] = parsed.defaultValue;
196
301
  }
197
302
  for (const [key, value] of Object.entries(normalizedConfig?.defaults ?? {})) {
198
303
  defaults[key] = value === undefined || value === null ? "" : String(value);
@@ -256,7 +361,7 @@ export function expandCommandTemplateExecutable(
256
361
 
257
362
  function evaluateCommandTemplateExpression(
258
363
  expression: string,
259
- values: Record<string, string>,
364
+ values: Record<string, unknown>,
260
365
  ): number {
261
366
  let index = 0;
262
367
  const source = expression.replace(/\s+/g, "");
@@ -283,7 +388,7 @@ function evaluateCommandTemplateExpression(
283
388
  if (nameMatch) {
284
389
  index += nameMatch[0].length;
285
390
  const value = values[nameMatch[0]];
286
- if (value === undefined || !/^-?\d+$/.test(value))
391
+ if (value === undefined || !/^-?\d+$/.test(String(value)))
287
392
  throw new Error(`Invalid command template expression variable: ${nameMatch[0]}`);
288
393
  return Number(value);
289
394
  }
@@ -313,7 +418,7 @@ function evaluateCommandTemplateExpression(
313
418
 
314
419
  function substituteCommandTemplateExpression(
315
420
  content: string,
316
- values: Record<string, string>,
421
+ values: Record<string, unknown>,
317
422
  ): string | undefined {
318
423
  const padded = content.match(/^(_{1,6})\((.+)\)$/);
319
424
  if (padded) {
@@ -323,22 +428,50 @@ function substituteCommandTemplateExpression(
323
428
  return String(evaluateCommandTemplateExpression(content, values));
324
429
  }
325
430
 
431
+ function resolveCommandTemplateValue(
432
+ content: string,
433
+ values: Record<string, unknown>,
434
+ missingLabel: string,
435
+ depth = 0,
436
+ ): string | undefined {
437
+ if (depth > 5) throw new Error(`Command template value recursion exceeded: ${content}`);
438
+ const indexed = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]$/);
439
+ if (indexed) {
440
+ const source = values[indexed[1]];
441
+ const indexValue = /^\d+$/.test(indexed[2]) ? indexed[2] : values[indexed[2]];
442
+ const index = Number(indexValue);
443
+ if (!Array.isArray(source) || !Number.isInteger(index) || index < 0 || index >= source.length) {
444
+ throw new Error(`Missing ${missingLabel} value: ${content}`);
445
+ }
446
+ return String(source[index] ?? "");
447
+ }
448
+ const simple = parseCommandTemplatePlaceholderContent(content);
449
+ if (simple) {
450
+ if (Object.hasOwn(values, simple.name)) {
451
+ const raw = values[simple.name] ?? "";
452
+ if (typeof raw === "string" && /^\{[^{}]+\}$/.test(raw)) {
453
+ return substituteCommandTemplateToken(raw, values, missingLabel, depth + 1);
454
+ }
455
+ return Array.isArray(raw) ? JSON.stringify(raw) : String(raw);
456
+ }
457
+ if (simple.inlineDefault !== undefined) return simple.inlineDefault;
458
+ }
459
+ const expression = substituteCommandTemplateExpression(content, values);
460
+ if (expression !== undefined) return expression;
461
+ return undefined;
462
+ }
463
+
326
464
  export function substituteCommandTemplateToken(
327
465
  token: string,
328
- values: Record<string, string>,
466
+ values: Record<string, unknown>,
329
467
  missingLabel = "command template",
468
+ depth = 0,
330
469
  ): string {
331
470
  return token.replace(
332
471
  /\{([^{}]+)\}/g,
333
472
  (_match, content: string) => {
334
- const simple = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?$/);
335
- if (simple) {
336
- const [, name, inlineDefault] = simple;
337
- if (Object.hasOwn(values, name)) return values[name] ?? "";
338
- if (inlineDefault !== undefined) return inlineDefault;
339
- }
340
- const expression = substituteCommandTemplateExpression(content, values);
341
- if (expression !== undefined) return expression;
473
+ const resolved = resolveCommandTemplateValue(content, values, missingLabel, depth);
474
+ if (resolved !== undefined) return resolved;
342
475
  throw new Error(`Missing ${missingLabel} value: ${content}`);
343
476
  },
344
477
  );
@@ -405,8 +538,6 @@ function execCommandTemplateOnce(
405
538
  }
406
539
  if (options.timeout !== undefined && options.timeout > 0)
407
540
  timeoutId = setTimeout(killProcess, options.timeout);
408
- else if (options.timeout === undefined)
409
- timeoutId = setTimeout(killProcess, DEFAULT_COMMAND_TIMEOUT_MS);
410
541
  proc.stdout?.on("data", (data) => {
411
542
  stdout += data.toString();
412
543
  });
@@ -427,7 +558,7 @@ function execCommandTemplateOnce(
427
558
 
428
559
  export function buildCommandTemplateInvocation(
429
560
  config: CommandTemplateConfig,
430
- values: Record<string, string>,
561
+ values: Record<string, unknown>,
431
562
  cwd: string,
432
563
  options: { emptyMessage?: string; missingLabel?: string } = {},
433
564
  ): CommandTemplateInvocation {
package/lib/config.ts CHANGED
@@ -35,7 +35,7 @@ export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConf
35
35
  timeout?: number;
36
36
  }
37
37
 
38
- export type TelegramTimeMode = "off" | "always" | "interval";
38
+ export type TelegramTimeMode = "hidden" | "always" | "interval";
39
39
 
40
40
  export interface TelegramTimeConfig {
41
41
  injectionMode?: TelegramTimeMode;
@@ -240,7 +240,7 @@ export function resolveTelegramTimeConfig(
240
240
  const injectionMode: TelegramTimeMode =
241
241
  raw?.injectionMode === "always" || raw?.injectionMode === "interval"
242
242
  ? raw.injectionMode
243
- : "off";
243
+ : "hidden";
244
244
  const interval =
245
245
  typeof raw?.interval === "number" && raw.interval > 0
246
246
  ? raw.interval
package/lib/lifecycle.ts CHANGED
@@ -10,6 +10,8 @@ import type {
10
10
  BeforeAgentStartEvent,
11
11
  ExtensionAPI,
12
12
  ExtensionContext,
13
+ SessionBeforeCompactEvent,
14
+ SessionCompactEvent,
13
15
  SessionShutdownEvent,
14
16
  SessionStartEvent,
15
17
  } from "./pi.ts";
@@ -50,6 +52,14 @@ export interface TelegramLifecycleRegistrationDeps {
50
52
  event: SessionShutdownEvent,
51
53
  ctx: ExtensionContext,
52
54
  ) => Promise<void>;
55
+ onSessionBeforeCompact?: (
56
+ event: SessionBeforeCompactEvent,
57
+ ctx: ExtensionContext,
58
+ ) => Promise<void> | void;
59
+ onSessionCompact?: (
60
+ event: SessionCompactEvent,
61
+ ctx: ExtensionContext,
62
+ ) => Promise<void> | void;
53
63
  onBeforeAgentStart: (
54
64
  event: BeforeAgentStartEvent,
55
65
  ctx: ExtensionContext,
@@ -92,6 +102,76 @@ export interface TelegramSessionLifecycleHooks {
92
102
  ) => Promise<void>;
93
103
  }
94
104
 
105
+ type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
106
+
107
+ export interface TelegramCompactionObserverRuntimeDeps<TContext> {
108
+ setCompactionInProgress: (inProgress: boolean) => void;
109
+ updateStatus: (ctx: TContext) => void;
110
+ requestDeferredDispatchNextQueuedTelegramTurn: (
111
+ dispatch: (ctx: TContext) => void,
112
+ ) => void;
113
+ dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
114
+ recordRuntimeEvent?: (category: string, error: unknown) => void;
115
+ timeoutMs?: number;
116
+ setTimer?: (
117
+ callback: () => void,
118
+ ms: number,
119
+ ) => TelegramLifecycleTimer;
120
+ clearTimer?: (timer: TelegramLifecycleTimer) => void;
121
+ }
122
+
123
+ export interface TelegramCompactionObserverRuntime<TContext> {
124
+ onSessionBeforeCompact: (
125
+ event: SessionBeforeCompactEvent,
126
+ ctx: TContext,
127
+ ) => void;
128
+ onSessionCompact: (event: SessionCompactEvent, ctx: TContext) => void;
129
+ onSessionShutdown: () => void;
130
+ }
131
+
132
+ export function createTelegramCompactionObserverRuntime<TContext>(
133
+ deps: TelegramCompactionObserverRuntimeDeps<TContext>,
134
+ ): TelegramCompactionObserverRuntime<TContext> {
135
+ const timeoutMs = deps.timeoutMs ?? 300_000;
136
+ const setTimer = deps.setTimer ?? setTimeout;
137
+ const clearTimer = deps.clearTimer ?? clearTimeout;
138
+ let fallbackTimer: TelegramLifecycleTimer | undefined;
139
+ const clearFallbackTimer = (): void => {
140
+ if (!fallbackTimer) return;
141
+ clearTimer(fallbackTimer);
142
+ fallbackTimer = undefined;
143
+ };
144
+ const requestDispatch = (): void => {
145
+ deps.requestDeferredDispatchNextQueuedTelegramTurn(
146
+ deps.dispatchNextQueuedTelegramTurn,
147
+ );
148
+ };
149
+ return {
150
+ onSessionBeforeCompact: (_event, ctx) => {
151
+ deps.setCompactionInProgress(true);
152
+ deps.updateStatus(ctx);
153
+ clearFallbackTimer();
154
+ fallbackTimer = setTimer(() => {
155
+ fallbackTimer = undefined;
156
+ deps.setCompactionInProgress(false);
157
+ deps.updateStatus(ctx);
158
+ deps.recordRuntimeEvent?.(
159
+ "compact",
160
+ new Error("Compaction observer timed out"),
161
+ );
162
+ requestDispatch();
163
+ }, timeoutMs);
164
+ },
165
+ onSessionCompact: (_event, ctx) => {
166
+ clearFallbackTimer();
167
+ deps.setCompactionInProgress(false);
168
+ deps.updateStatus(ctx);
169
+ requestDispatch();
170
+ },
171
+ onSessionShutdown: clearFallbackTimer,
172
+ };
173
+ }
174
+
95
175
  export function createDedupAgentStartHook(
96
176
  dedup: { reset(): void },
97
177
  inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
@@ -139,6 +219,12 @@ export function registerTelegramLifecycleHooks(
139
219
  pi.on("session_shutdown", async (event, ctx) => {
140
220
  await deps.onSessionShutdown(event, ctx);
141
221
  });
222
+ pi.on("session_before_compact", async (event, ctx) => {
223
+ await deps.onSessionBeforeCompact?.(event, ctx);
224
+ });
225
+ pi.on("session_compact", async (event, ctx) => {
226
+ await deps.onSessionCompact?.(event, ctx);
227
+ });
142
228
  pi.on("before_agent_start", async (event, ctx) => {
143
229
  return deps.onBeforeAgentStart(event, ctx);
144
230
  });
@@ -135,17 +135,24 @@ export function buildTelegramSettingsMenuText(): string {
135
135
  return SETTINGS_MENU_TITLE;
136
136
  }
137
137
 
138
- export function buildProactivePushSettingsText(): string {
138
+ export function buildProactivePushSettingsText(
139
+ proactivePushEnabled: boolean,
140
+ ): string {
139
141
  return [
140
- PROACTIVE_PUSH_SETTINGS_TITLE,
142
+ `${PROACTIVE_PUSH_SETTINGS_TITLE} <code>${proactivePushEnabled ? "on" : "off"}</code>`,
141
143
  "",
142
144
  "Send successful local π task results to Telegram when the bridge is connected.",
143
145
  ].join("\n");
144
146
  }
145
147
 
146
- export function buildVoiceReplyModeSettingsText(): string {
148
+ export function buildVoiceReplyModeSettingsText(
149
+ mode: TelegramVoiceReplyMode,
150
+ configured = true,
151
+ ): string {
147
152
  return [
148
- VOICE_REPLY_MODE_SETTINGS_TITLE,
153
+ `${VOICE_REPLY_MODE_SETTINGS_TITLE} <code>${getVoiceReplyModeLabel(
154
+ getVoiceReplyModeSetting(mode, configured),
155
+ )}</code>`,
149
156
  "",
150
157
  "Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
151
158
  "",
@@ -156,13 +163,15 @@ export function buildVoiceReplyModeSettingsText(): string {
156
163
  ].join("\n");
157
164
  }
158
165
 
159
- export function buildTimeInjectionModeSettingsText(): string {
166
+ export function buildTimeInjectionModeSettingsText(
167
+ mode: TelegramTimeMode,
168
+ ): string {
160
169
  return [
161
- TIME_INJECTION_MODE_SETTINGS_TITLE,
170
+ `${TIME_INJECTION_MODE_SETTINGS_TITLE} <code>${mode}</code>`,
162
171
  "",
163
172
  "Controls whether Telegram-originated prompts include a compact wall-clock [time] line.",
164
173
  "",
165
- "<code>-</code> <code>off</code> (default): no time line is added.",
174
+ "<code>-</code> <code>hidden</code> (default): no time line is added to prompt context.",
166
175
  "<code>-</code> <code>always</code>: add time to every Telegram turn.",
167
176
  "<code>-</code> <code>interval</code>: add time at most once per chat interval (default: 1 hour).",
168
177
  ].join("\n");
@@ -257,7 +266,7 @@ export function buildProactivePushSettingsReplyMarkup(
257
266
  export function buildTimeInjectionModeSettingsReplyMarkup(
258
267
  mode: TelegramTimeMode,
259
268
  ): TelegramSettingsMenuReplyMarkup {
260
- const modes: TelegramTimeMode[] = ["off", "always", "interval"];
269
+ const modes: TelegramTimeMode[] = ["hidden", "always", "interval"];
261
270
  return {
262
271
  inline_keyboard: [
263
272
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
@@ -314,30 +323,31 @@ export async function updateTelegramSettingsMenuMessage(
314
323
  export async function updateProactivePushSettingsMessage(
315
324
  deps: TelegramSettingsMenuCallbackDeps,
316
325
  ): Promise<void> {
326
+ const proactivePushEnabled = deps.isProactivePushEnabled();
317
327
  await deps.updateSettingsMessage(
318
- buildProactivePushSettingsText(),
319
- buildProactivePushSettingsReplyMarkup(deps.isProactivePushEnabled()),
328
+ buildProactivePushSettingsText(proactivePushEnabled),
329
+ buildProactivePushSettingsReplyMarkup(proactivePushEnabled),
320
330
  );
321
331
  }
322
332
 
323
333
  export async function updateTimeInjectionModeSettingsMessage(
324
334
  deps: TelegramSettingsMenuCallbackDeps,
325
335
  ): Promise<void> {
336
+ const mode = deps.getTimeInjectionMode();
326
337
  await deps.updateSettingsMessage(
327
- buildTimeInjectionModeSettingsText(),
328
- buildTimeInjectionModeSettingsReplyMarkup(deps.getTimeInjectionMode()),
338
+ buildTimeInjectionModeSettingsText(mode),
339
+ buildTimeInjectionModeSettingsReplyMarkup(mode),
329
340
  );
330
341
  }
331
342
 
332
343
  export async function updateVoiceReplyModeSettingsMessage(
333
344
  deps: TelegramSettingsMenuCallbackDeps,
334
345
  ): Promise<void> {
346
+ const mode = deps.getVoiceReplyMode();
347
+ const configured = deps.isVoiceReplyModeConfigured();
335
348
  await deps.updateSettingsMessage(
336
- buildVoiceReplyModeSettingsText(),
337
- buildVoiceReplyModeSettingsReplyMarkup(
338
- deps.getVoiceReplyMode(),
339
- deps.isVoiceReplyModeConfigured(),
340
- ),
349
+ buildVoiceReplyModeSettingsText(mode, configured),
350
+ buildVoiceReplyModeSettingsReplyMarkup(mode, configured),
341
351
  );
342
352
  }
343
353
 
@@ -391,10 +401,18 @@ export async function handleTelegramSettingsMenuCallbackAction(
391
401
  const mode = data.startsWith("settings:set:time-injection:")
392
402
  ? data.slice("settings:set:time-injection:".length)
393
403
  : data.slice("settings:set:time:".length);
394
- if (mode === "off" || mode === "always" || mode === "interval") {
395
- await deps.setTimeInjectionMode(mode);
404
+ const normalizedMode = mode === "off" ? "hidden" : mode;
405
+ if (
406
+ normalizedMode === "hidden" ||
407
+ normalizedMode === "always" ||
408
+ normalizedMode === "interval"
409
+ ) {
410
+ await deps.setTimeInjectionMode(normalizedMode);
396
411
  await updateTimeInjectionModeSettingsMessage(deps);
397
- await deps.answerCallbackQuery(callbackQueryId, `Time injection: ${mode}`);
412
+ await deps.answerCallbackQuery(
413
+ callbackQueryId,
414
+ `Time injection: ${normalizedMode}`,
415
+ );
398
416
  return true;
399
417
  }
400
418
  }
@@ -490,13 +508,15 @@ export function createTelegramSettingsMenuRuntime<
490
508
  if (
491
509
  (hasTimeInjectionPrefix || query.data.startsWith("settings:set:time:")) &&
492
510
  (timeMode === "off" ||
511
+ timeMode === "hidden" ||
493
512
  timeMode === "always" ||
494
513
  timeMode === "interval")
495
514
  ) {
496
- await deps.setTimeInjectionMode(timeMode);
515
+ const normalizedMode = timeMode === "off" ? "hidden" : timeMode;
516
+ await deps.setTimeInjectionMode(normalizedMode);
497
517
  await deps.answerCallbackQuery(
498
518
  query.id,
499
- `Time injection: ${timeMode}`,
519
+ `Time injection: ${normalizedMode}`,
500
520
  );
501
521
  return true;
502
522
  }