@llblab/pi-telegram 0.11.0 → 0.11.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/AGENTS.md CHANGED
@@ -73,7 +73,8 @@
73
73
  - Telegram delivery strips top-level HTML comments from preview/final text; column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks are special outbound comments handled after `agent_end` without requiring agent-side transport tool calls, while comments inside code, quotes, lists, or indented examples stay literal
74
74
  - `telegram_voice` and `telegram_button` are not π tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
75
75
  - Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing/invalid config behaves as `manual` but does not add a `[voice]` prompt-context block; only an explicit valid `voice.replyMode` renders context. Render a single voice field as `[voice] reply mode: manual|mirror|always`, and render multiple fields as a `[voice]` list; place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy channel
76
- - Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|manual|mirror|always`; `hidden` is the true default and means no valid `voice.replyMode` is persisted, behavior is manual, and no voice policy is added to prompt context; explicit `manual` behaves the same operationally but renders reply-mode context. The submenu title is `Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
76
+ - Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is off by default, uses `time.injectionMode` values `off|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: off|always|interval` controls `time.injectionMode` only.
77
+ - Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|manual|mirror|always`; `hidden` is the true default and means no valid `voice.replyMode` is persisted, behavior is manual, and no voice policy is added to prompt context; explicit `manual` behaves the same operationally but renders reply-mode context. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
77
78
  - Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
78
79
  - `telegram_voice` text is arbitrary TTS-target text: use body form for multiline text, `<!-- telegram_voice text="Short summary" -->` for explicit one-line text, or `<!-- telegram_voice: Short summary -->` for one-line text with no attributes
79
80
  - `telegram_button` has three canonical forms: `<!-- telegram_button: OK -->` for label-only buttons, `<!-- telegram_button label=Continue prompt="Continue with the current plan." -->` for one-line prompts, or `<!-- telegram_button label="Show risks"\nList the main risks first.\n-->` for multiline prompts
@@ -128,7 +129,7 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
128
129
  - When durable runtime constraints or repeat bug patterns emerge, record them here instead of burying them in changelog prose
129
130
  - When fork identity changes, keep `README.md`, package metadata, and docs aligned so the published package does not point back at stale upstream coordinates
130
131
  - README positioning should emphasize `/start` as the primary Telegram operator menu and keep reaction shortcuts secondary. Reactions are useful queue affordances, but menu controls are the core CLI-to-Telegram bridge.
131
- - Document configuration knobs without UI in the root README when they affect bootstrap, networking, or transport limits; currently this includes token env bootstrap, Node env proxy mode, and inbound/outbound size limits.
132
+ - Document configuration knobs without UI in the root README when they affect bootstrap, networking, transport limits, or prompt context; currently this includes token env bootstrap, Node env proxy mode, inbound/outbound size limits, and `time`.
132
133
  - Work only inside this repository during development tasks; updating the installed Pi extension checkout is a separate manual operator step, not part of normal in-repo implementation work
133
134
 
134
135
  ## 8. Integration Protocols
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.11.1: Time Context And Settings Polish
6
+
7
+ - `[Time Context]` Added optional `telegram.json` `time` prompt context for Telegram-originated turns. `time.injectionMode` values are `off`, `always`, and per-chat `interval`; `time.interval` is stored in milliseconds and timezone comes from the system. The `[time]` line renders last after attachments, handler outputs, and voice context, and Settings exposes a `🕒 Time` mode selector.
8
+ - `[Settings UI]` The proactive push row now uses `📌 Proactive push: on|off`, and proactive push, time, and voice reply submenus use matching emoji headings.
9
+
3
10
  ## 0.11.0: Voice Provider Platform
4
11
 
5
12
  - `[Voice Synthesis Provider API]` Added a first-class voice synthesis provider surface for Telegram voice replies. Providers register with `registerTelegramVoiceSynthesisProvider()` from `@llblab/pi-telegram/lib/voice.ts`, synthesize text into `.ogg`/`.opus`, may return `{ audioPath, transcriptText }`, and can contribute voice-specific prompt guidance through `getVoicePromptContribution(view)`.
package/README.md CHANGED
@@ -238,6 +238,21 @@ Import `registerTelegramSection()` from `@llblab/pi-telegram/lib/extension-secti
238
238
 
239
239
  `telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active. Local prompt text is not mirrored because the bot does not own terminal user messages. The mode is off by default and can be toggled from settings.
240
240
 
241
+ ### Time context
242
+
243
+ `telegram.json` can opt into a compact `[time]` line in Telegram-originated prompts so π has a wall-clock reference for requests such as "today", "now", or scheduling. It is off by default and uses the system timezone; the mode can also be changed from Settings → `🕒 Time`.
244
+
245
+ ```json
246
+ {
247
+ "time": {
248
+ "injectionMode": "interval",
249
+ "interval": 3600000
250
+ }
251
+ }
252
+ ```
253
+
254
+ Modes are `off`, `always`, and `interval`. `interval` is measured in milliseconds and rate-limits the time line per chat in memory, so back-to-back messages do not repeatedly spend context on the same timestamp. When present, `[time]` is the final prompt-context section after attachments, handler outputs, and voice policy.
255
+
241
256
  ## Docs
242
257
 
243
258
  - [Project Context](./AGENTS.md): durable engineering conventions and architecture constraints.
@@ -28,7 +28,7 @@ Current runtime areas use these ownership boundaries:
28
28
  - `config` / `setup`: persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, and config persistence.
29
29
  - `locks` / `polling`: singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, and poll-loop runtime wiring.
30
30
  - `updates` / `routing`: update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition.
31
- - `media` / `text-groups` / `turns` / `inbound-handlers`: text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, configured and programmatic inbound text/media handler execution, turn building/editing, image reads, and legacy `attachmentHandlers` compatibility.
31
+ - `media` / `text-groups` / `time-injection` / `turns` / `inbound-handlers`: text/media extraction, media-group debounce, long-text split coalescing, optional per-chat wall-clock prompt context, inbound downloads, configured and programmatic inbound text/media handler execution, turn building/editing, image reads, and legacy `attachmentHandlers` compatibility.
32
32
  - `queue`: queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, and session/agent/tool lifecycle sequencing.
33
33
  - `runtime`: session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, and agent-end reset binding.
34
34
  - `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `menu-settings` / `commands`: model identity/thinking levels, scoped model resolution, in-flight switching, model/thinking/status/queue/settings menu UI, inline application callback composition, slash commands, and bot command registration.
@@ -79,9 +79,10 @@ Telegram bot configuration stays in `~/.pi/agent/telegram.json`; singleton runti
79
79
  7. Configured inbound handlers may run on raw text or downloaded files by MIME wildcard, Telegram attachment type, or generic match selector; command templates receive safe command-arg substitution for `{text}`, `{file}`, `{mime}`, and `{type}` where applicable
80
80
  8. Matching media/file handlers are tried in config order: a non-zero exit records diagnostics and falls back to the next matching handler, while the first successful handler stops the chain
81
81
  9. Local attachments stay visible under `[attachments] <directory>` with relative file entries, and handler stdout is appended under `[outputs]` before the agent sees the turn; failed handlers omit output while keeping the attachment entry
82
- 10. A `PendingTelegramTurn` is created and queued locally
83
- 11. Telegram `edited_message` updates are routed separately and update a matching queued turn when the original message has not been dispatched yet
84
- 12. The queue dispatcher sends the turn into π only when dispatch is safe
82
+ 10. Optional `time` config may add a compact final `[time]` prompt line after attachment/output/voice sections, either every turn or per-chat after the configured millisecond interval, using the system timezone
83
+ 11. A `PendingTelegramTurn` is created and queued locally
84
+ 12. Telegram `edited_message` updates are routed separately and update a matching queued turn when the original message has not been dispatched yet
85
+ 13. The queue dispatcher sends the turn into π only when dispatch is safe
85
86
 
86
87
  ### Queue Safety Model
87
88
 
@@ -114,7 +115,7 @@ Dispatch is gated by:
114
115
 
115
116
  This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/start` and hidden compatibility shortcuts `/status`, `/model`, `/thinking`, `/queue`, and `/settings` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
116
117
 
117
- `/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The `Status` row reports `compacting` while a Telegram `/compact` run is active, and the bridge sends Telegram's native `typing` chat action as a keepalive for the same compaction window. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens, while `/settings` opens the hidden settings menu for bridge toggles such as proactive push. Settings options open detail submenus; checkbox-like settings use Back plus green/black/yellow `on` and `off` controls instead of mutating directly from the list. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`, stay visible only inside the `/start` menu, and expand before queueing because `ExtensionAPI.sendUserMessage()` intentionally bypasses π prompt-template expansion for extension-originated messages. Every submenu starts with a top Back row so navigation stays anchored near the original user message above the inline keyboard; model-menu pagination controls sit near the top, tapping the pagination indicator opens a compact page picker headed by `<b>Choose a page:</b>`, and tapping a model opens a detail submenu with Back, ☑️ Activate/🟢 Active selection, and yellow/black-marked Scoped/All membership tabs. `menu-model` owns model-menu state, scoped model pages, model detail rendering, scoped-list persistence planning, and model-menu rendering while `model` owns core model identity/switching semantics. `menu-thinking` owns thinking-menu text, reply markup, callback handling, and message rendering. `menu-status` owns status-menu payloads, status callback handling, and status-message rendering. `menu-queue` owns queue-menu UI only: queue items are rendered under a compact `<b>Queue:</b>` heading, top-to-bottom in dispatch order, numbered, and marked with `⚡` for priority prompts or `📎` for prompts with attachments. An empty queue renders bold message text with the bottom-filled `⌛` hourglass plus the top Main menu button, while non-empty queue states keep the running `⏳` hourglass. Selecting an item opens a submenu that displays the queue item number above the full queued prompt text with Back, side-by-side Priority/Normal tabs, and Cancel. If a callback targets an item that has already left the queue, the menu refreshes the list instead of applying a stale mutation.
118
+ `/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The `Status` row reports `compacting` while a Telegram `/compact` run is active, and the bridge sends Telegram's native `typing` chat action as a keepalive for the same compaction window. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens, while `/settings` opens the hidden settings menu for bridge toggles such as proactive push, voice reply mode, and `time.injectionMode`. Settings options open detail submenus; checkbox-like settings use Back plus green/black/yellow `on` and `off` controls instead of mutating directly from the list. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`, stay visible only inside the `/start` menu, and expand before queueing because `ExtensionAPI.sendUserMessage()` intentionally bypasses π prompt-template expansion for extension-originated messages. Every submenu starts with a top Back row so navigation stays anchored near the original user message above the inline keyboard; model-menu pagination controls sit near the top, tapping the pagination indicator opens a compact page picker headed by `<b>Choose a page:</b>`, and tapping a model opens a detail submenu with Back, ☑️ Activate/🟢 Active selection, and yellow/black-marked Scoped/All membership tabs. `menu-model` owns model-menu state, scoped model pages, model detail rendering, scoped-list persistence planning, and model-menu rendering while `model` owns core model identity/switching semantics. `menu-thinking` owns thinking-menu text, reply markup, callback handling, and message rendering. `menu-status` owns status-menu payloads, status callback handling, and status-message rendering. `menu-queue` owns queue-menu UI only: queue items are rendered under a compact `<b>Queue:</b>` heading, top-to-bottom in dispatch order, numbered, and marked with `⚡` for priority prompts or `📎` for prompts with attachments. An empty queue renders bold message text with the bottom-filled `⌛` hourglass plus the top Main menu button, while non-empty queue states keep the running `⏳` hourglass. Selecting an item opens a submenu that displays the queue item number above the full queued prompt text with Back, side-by-side Priority/Normal tabs, and Cancel. If a callback targets an item that has already left the queue, the menu refreshes the list instead of applying a stale mutation.
118
119
 
119
120
  ### Abort Behavior
120
121
 
package/index.ts CHANGED
@@ -38,6 +38,7 @@ import * as Runtime from "./lib/runtime.ts";
38
38
  import * as Setup from "./lib/setup.ts";
39
39
  import * as Status from "./lib/status.ts";
40
40
  import * as TextGroups from "./lib/text-groups.ts";
41
+ import * as TimeInjection from "./lib/time-injection.ts";
41
42
  import * as Voice from "./lib/voice.ts";
42
43
 
43
44
  const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
@@ -113,6 +114,10 @@ export default function (pi: Pi.ExtensionAPI) {
113
114
  Config.createTelegramVoiceReplyModeConfiguredChecker(configStore);
114
115
  const setVoiceReplyMode =
115
116
  Config.createTelegramVoiceReplyModeSetter(configStore);
117
+ const getTimeInjectionMode =
118
+ Config.createTelegramTimeInjectionModeGetter(configStore);
119
+ const setTimeInjectionMode =
120
+ Config.createTelegramTimeInjectionModeSetter(configStore);
116
121
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
117
122
  const lockOwnershipGuard =
118
123
  Locks.createTelegramLockOwnershipGuard(lockRuntime);
@@ -137,6 +142,10 @@ export default function (pi: Pi.ExtensionAPI) {
137
142
  getBotToken: configStore.getBotToken,
138
143
  });
139
144
  const recordRuntimeEvent = runtimeEvents.record;
145
+ const timeInjectionRuntime = TimeInjection.createTimeInjectionRuntime({
146
+ getConfig: Config.createTelegramTimeConfigGetter(configStore),
147
+ recordRuntimeEvent,
148
+ });
140
149
  (globalThis as Record<string, unknown>)[
141
150
  VOICE_EVENT_RECORDER_KEY
142
151
  ] = recordRuntimeEvent;
@@ -386,8 +395,10 @@ export default function (pi: Pi.ExtensionAPI) {
386
395
  isProactivePushEnabled,
387
396
  getVoiceReplyMode,
388
397
  isVoiceReplyModeConfigured,
398
+ getTimeInjectionMode,
389
399
  setProactivePushEnabled,
390
400
  setVoiceReplyMode,
401
+ setTimeInjectionMode,
391
402
  },
392
403
  sectionRegistry,
393
404
  );
@@ -435,6 +446,7 @@ export default function (pi: Pi.ExtensionAPI) {
435
446
  setMyCommands,
436
447
  getCommands,
437
448
  downloadFile: downloadTelegramBridgeFile,
449
+ resolveTimeLine: timeInjectionRuntime.resolveLine,
438
450
  getThinkingLevel,
439
451
  setThinkingLevel,
440
452
  persistScopedModelPatterns: Pi.createScopedModelPatternPersister({
package/lib/config.ts CHANGED
@@ -35,6 +35,19 @@ export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConf
35
35
  timeout?: number;
36
36
  }
37
37
 
38
+ export type TelegramTimeMode = "off" | "always" | "interval";
39
+
40
+ export interface TelegramTimeConfig {
41
+ injectionMode?: TelegramTimeMode;
42
+ interval?: number;
43
+ }
44
+
45
+ export interface ResolvedTelegramTimeConfig {
46
+ injectionMode: TelegramTimeMode;
47
+ interval: number;
48
+ timezone: string;
49
+ }
50
+
38
51
  export interface TelegramConfig {
39
52
  botToken?: string;
40
53
  botUsername?: string;
@@ -50,6 +63,7 @@ export interface TelegramConfig {
50
63
  /** Whether to attach the provider's transcriptText as caption on voice messages */
51
64
  sendTranscript?: boolean;
52
65
  };
66
+ time?: TelegramTimeConfig;
53
67
  }
54
68
 
55
69
  export interface TelegramConfigStore {
@@ -211,6 +225,56 @@ export function createTelegramVoiceReplyModeSetter(
211
225
  };
212
226
  }
213
227
 
228
+ function getSystemTimezone(): string {
229
+ try {
230
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
231
+ return tz && tz.length > 0 ? tz : "UTC";
232
+ } catch {
233
+ return "UTC";
234
+ }
235
+ }
236
+
237
+ export function resolveTelegramTimeConfig(
238
+ raw: TelegramTimeConfig | undefined,
239
+ ): ResolvedTelegramTimeConfig {
240
+ const injectionMode: TelegramTimeMode =
241
+ raw?.injectionMode === "always" || raw?.injectionMode === "interval"
242
+ ? raw.injectionMode
243
+ : "off";
244
+ const interval =
245
+ typeof raw?.interval === "number" && raw.interval > 0
246
+ ? raw.interval
247
+ : 60 * 60 * 1000;
248
+ const timezone = getSystemTimezone();
249
+ return { injectionMode, interval, timezone };
250
+ }
251
+
252
+ export function createTelegramTimeConfigGetter(
253
+ configStore: Pick<TelegramConfigStore, "get">,
254
+ ): () => ResolvedTelegramTimeConfig {
255
+ return () => resolveTelegramTimeConfig(configStore.get().time);
256
+ }
257
+
258
+ export function createTelegramTimeInjectionModeGetter(
259
+ configStore: Pick<TelegramConfigStore, "get">,
260
+ ): () => TelegramTimeMode {
261
+ return () => resolveTelegramTimeConfig(configStore.get().time).injectionMode;
262
+ }
263
+
264
+ export function createTelegramTimeInjectionModeSetter(
265
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
266
+ ): (injectionMode: TelegramTimeMode) => Promise<void> {
267
+ return async (injectionMode) => {
268
+ const current = configStore.get();
269
+ const next = {
270
+ ...current,
271
+ time: { ...(current.time ?? {}), injectionMode },
272
+ };
273
+ configStore.set(next);
274
+ await configStore.persist(next);
275
+ };
276
+ }
277
+
214
278
  export function createTelegramProactivePushChatIdGetter(deps: {
215
279
  getActiveTurnChatId: () => number | undefined;
216
280
  getAllowedUserId: () => number | undefined;
@@ -8,6 +8,7 @@ import {
8
8
  getTelegramExtensionSettingsRows,
9
9
  type TelegramSectionRegistry,
10
10
  } from "./extension-sections.ts";
11
+ import type { TelegramTimeMode } from "./config.ts";
11
12
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
12
13
  import type { TelegramModelMenuState } from "./menu-model.ts";
13
14
  import type { MenuModel } from "./model.ts";
@@ -17,6 +18,7 @@ export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
17
18
 
18
19
  export interface TelegramSettingsStateDeps {
19
20
  isProactivePushEnabled: () => boolean;
21
+ getTimeInjectionMode: () => TelegramTimeMode;
20
22
  getVoiceReplyMode: () => TelegramVoiceReplyMode;
21
23
  isVoiceReplyModeConfigured: () => boolean;
22
24
  }
@@ -26,6 +28,7 @@ export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps
26
28
  setVoiceReplyMode: (
27
29
  mode: TelegramVoiceReplyMode | undefined,
28
30
  ) => Promise<void>;
31
+ setTimeInjectionMode: (mode: TelegramTimeMode) => Promise<void>;
29
32
  }
30
33
 
31
34
  export interface TelegramSettingsMenuOpenDeps<
@@ -111,8 +114,9 @@ export interface TelegramSettingsMenuRuntimeDeps<
111
114
  }
112
115
 
113
116
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
114
- export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>Proactive push:</b>";
115
- export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>Voice reply mode:</b>";
117
+ export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
118
+ export const TIME_INJECTION_MODE_SETTINGS_TITLE = "<b>🕒 Time injection mode:</b>";
119
+ export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
116
120
 
117
121
  type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
118
122
 
@@ -152,9 +156,22 @@ export function buildVoiceReplyModeSettingsText(): string {
152
156
  ].join("\n");
153
157
  }
154
158
 
159
+ export function buildTimeInjectionModeSettingsText(): string {
160
+ return [
161
+ TIME_INJECTION_MODE_SETTINGS_TITLE,
162
+ "",
163
+ "Controls whether Telegram-originated prompts include a compact wall-clock [time] line.",
164
+ "",
165
+ "<code>-</code> <code>off</code> (default): no time line is added.",
166
+ "<code>-</code> <code>always</code>: add time to every Telegram turn.",
167
+ "<code>-</code> <code>interval</code>: add time at most once per chat interval (default: 1 hour).",
168
+ ].join("\n");
169
+ }
170
+
155
171
  export function buildTelegramSettingsMenuReplyMarkup(
156
172
  proactivePushEnabled: boolean,
157
173
  voiceReplyMode: TelegramVoiceReplyMode,
174
+ timeInjectionMode: TelegramTimeMode,
158
175
  sectionRegistry?: TelegramSectionRegistry,
159
176
  voiceReplyModeConfigured = true,
160
177
  ): TelegramSettingsMenuReplyMarkup {
@@ -179,7 +196,13 @@ export function buildTelegramSettingsMenuReplyMarkup(
179
196
  ],
180
197
  [
181
198
  {
182
- text: `${proactivePushEnabled ? "🟢" : "⚫️"} Proactive push`,
199
+ text: `🕒 Time injection: ${timeInjectionMode}`,
200
+ callback_data: "settings:open:time-injection",
201
+ },
202
+ ],
203
+ [
204
+ {
205
+ text: `📌 Proactive push: ${proactivePushEnabled ? "on" : "off"}`,
183
206
  callback_data: "settings:open:proactive",
184
207
  },
185
208
  ],
@@ -200,6 +223,7 @@ export async function openTelegramSettingsMenu<
200
223
  buildTelegramSettingsMenuReplyMarkup(
201
224
  deps.isProactivePushEnabled(),
202
225
  deps.getVoiceReplyMode(),
226
+ deps.getTimeInjectionMode(),
203
227
  sectionRegistry,
204
228
  deps.isVoiceReplyModeConfigured(),
205
229
  ),
@@ -230,6 +254,23 @@ export function buildProactivePushSettingsReplyMarkup(
230
254
  };
231
255
  }
232
256
 
257
+ export function buildTimeInjectionModeSettingsReplyMarkup(
258
+ mode: TelegramTimeMode,
259
+ ): TelegramSettingsMenuReplyMarkup {
260
+ const modes: TelegramTimeMode[] = ["off", "always", "interval"];
261
+ return {
262
+ inline_keyboard: [
263
+ [{ text: "⬆️ Back", callback_data: "settings:list" }],
264
+ ...modes.map((value) => [
265
+ {
266
+ text: `${value === mode ? "🟢 " : ""}${value}`,
267
+ callback_data: `settings:set:time-injection:${value}`,
268
+ },
269
+ ]),
270
+ ],
271
+ };
272
+ }
273
+
233
274
  export function buildVoiceReplyModeSettingsReplyMarkup(
234
275
  mode: TelegramVoiceReplyMode,
235
276
  configured = true,
@@ -263,6 +304,7 @@ export async function updateTelegramSettingsMenuMessage(
263
304
  buildTelegramSettingsMenuReplyMarkup(
264
305
  deps.isProactivePushEnabled(),
265
306
  deps.getVoiceReplyMode(),
307
+ deps.getTimeInjectionMode(),
266
308
  sectionRegistry,
267
309
  deps.isVoiceReplyModeConfigured(),
268
310
  ),
@@ -278,6 +320,15 @@ export async function updateProactivePushSettingsMessage(
278
320
  );
279
321
  }
280
322
 
323
+ export async function updateTimeInjectionModeSettingsMessage(
324
+ deps: TelegramSettingsMenuCallbackDeps,
325
+ ): Promise<void> {
326
+ await deps.updateSettingsMessage(
327
+ buildTimeInjectionModeSettingsText(),
328
+ buildTimeInjectionModeSettingsReplyMarkup(deps.getTimeInjectionMode()),
329
+ );
330
+ }
331
+
281
332
  export async function updateVoiceReplyModeSettingsMessage(
282
333
  deps: TelegramSettingsMenuCallbackDeps,
283
334
  ): Promise<void> {
@@ -311,6 +362,11 @@ export async function handleTelegramSettingsMenuCallbackAction(
311
362
  await deps.answerCallbackQuery(callbackQueryId);
312
363
  return true;
313
364
  }
365
+ if (data === "settings:open:time-injection" || data === "settings:open:time") {
366
+ await updateTimeInjectionModeSettingsMessage(deps);
367
+ await deps.answerCallbackQuery(callbackQueryId);
368
+ return true;
369
+ }
314
370
  if (data.startsWith("settings:set:voice-reply:")) {
315
371
  const mode = data.slice("settings:set:voice-reply:".length);
316
372
  if (
@@ -328,6 +384,20 @@ export async function handleTelegramSettingsMenuCallbackAction(
328
384
  return true;
329
385
  }
330
386
  }
387
+ if (
388
+ data.startsWith("settings:set:time-injection:") ||
389
+ data.startsWith("settings:set:time:")
390
+ ) {
391
+ const mode = data.startsWith("settings:set:time-injection:")
392
+ ? data.slice("settings:set:time-injection:".length)
393
+ : data.slice("settings:set:time:".length);
394
+ if (mode === "off" || mode === "always" || mode === "interval") {
395
+ await deps.setTimeInjectionMode(mode);
396
+ await updateTimeInjectionModeSettingsMessage(deps);
397
+ await deps.answerCallbackQuery(callbackQueryId, `Time injection: ${mode}`);
398
+ return true;
399
+ }
400
+ }
331
401
  if (
332
402
  data === "settings:set:proactive:on" ||
333
403
  data === "settings:set:proactive:off"
@@ -360,6 +430,7 @@ export function createTelegramSettingsMenuRuntime<
360
430
  isProactivePushEnabled: deps.isProactivePushEnabled,
361
431
  getVoiceReplyMode: deps.getVoiceReplyMode,
362
432
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
433
+ getTimeInjectionMode: deps.getTimeInjectionMode,
363
434
  sendSettingsMenu: (state, text, replyMarkup) =>
364
435
  deps.sendInteractiveMessage(
365
436
  state.chatId,
@@ -377,6 +448,7 @@ export function createTelegramSettingsMenuRuntime<
377
448
  isProactivePushEnabled: deps.isProactivePushEnabled,
378
449
  getVoiceReplyMode: deps.getVoiceReplyMode,
379
450
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
451
+ getTimeInjectionMode: deps.getTimeInjectionMode,
380
452
  updateSettingsMessage: (text, replyMarkup) =>
381
453
  deps.editInteractiveMessage(
382
454
  state.chatId,
@@ -392,16 +464,40 @@ export function createTelegramSettingsMenuRuntime<
392
464
  if (!query.data?.startsWith("settings:")) return false;
393
465
  const state = deps.getStoredModelMenuState(query.message?.message_id);
394
466
  if (!state) {
395
- const mode = query.data.slice("settings:set:voice-reply:".length);
467
+ const voiceMode = query.data.slice("settings:set:voice-reply:".length);
396
468
  if (
397
469
  query.data.startsWith("settings:set:voice-reply:") &&
398
- (mode === "hidden" ||
399
- mode === "manual" ||
400
- mode === "mirror" ||
401
- mode === "always")
470
+ (voiceMode === "hidden" ||
471
+ voiceMode === "manual" ||
472
+ voiceMode === "mirror" ||
473
+ voiceMode === "always")
474
+ ) {
475
+ await deps.setVoiceReplyMode(
476
+ voiceMode === "hidden" ? undefined : voiceMode,
477
+ );
478
+ await deps.answerCallbackQuery(
479
+ query.id,
480
+ `Voice reply mode: ${voiceMode}`,
481
+ );
482
+ return true;
483
+ }
484
+ const hasTimeInjectionPrefix = query.data.startsWith(
485
+ "settings:set:time-injection:",
486
+ );
487
+ const timeMode = hasTimeInjectionPrefix
488
+ ? query.data.slice("settings:set:time-injection:".length)
489
+ : query.data.slice("settings:set:time:".length);
490
+ if (
491
+ (hasTimeInjectionPrefix || query.data.startsWith("settings:set:time:")) &&
492
+ (timeMode === "off" ||
493
+ timeMode === "always" ||
494
+ timeMode === "interval")
402
495
  ) {
403
- await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
404
- await deps.answerCallbackQuery(query.id, `Voice reply mode: ${mode}`);
496
+ await deps.setTimeInjectionMode(timeMode);
497
+ await deps.answerCallbackQuery(
498
+ query.id,
499
+ `Time injection: ${timeMode}`,
500
+ );
405
501
  return true;
406
502
  }
407
503
  await deps.answerCallbackQuery(
@@ -414,8 +510,10 @@ export function createTelegramSettingsMenuRuntime<
414
510
  isProactivePushEnabled: deps.isProactivePushEnabled,
415
511
  getVoiceReplyMode: deps.getVoiceReplyMode,
416
512
  isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
513
+ getTimeInjectionMode: deps.getTimeInjectionMode,
417
514
  setProactivePushEnabled: deps.setProactivePushEnabled,
418
515
  setVoiceReplyMode: deps.setVoiceReplyMode,
516
+ setTimeInjectionMode: deps.setTimeInjectionMode,
419
517
  updateSettingsMessage: (text, replyMarkup) =>
420
518
  deps.editInteractiveMessage(
421
519
  state.chatId,
package/lib/prompts.ts CHANGED
@@ -15,6 +15,7 @@ Inbound context:
15
15
  - \`[telegram]\` marks Telegram-originated messages. Suffixes \`|from:user\` (sender) and \`|guest:group\` (guest mode — message from another chat where the bot is not a member) may be present; the bot sees the message as if forwarded from that user/chat.
16
16
  - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Suffix \`|from:user\` identifies the original author in guest-mode replies. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
17
17
  - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
18
+ - \`[time]\` gives the wall-clock time for this Telegram turn when the operator enabled time injection. Use it for relative-date requests like "today", "now", or scheduling; otherwise do not mention it.
18
19
  - \`[voice]\` describes Telegram voice reply policy for this turn. \`manual\` means answer normally and use explicit \`telegram_voice\` markup only when a spoken reply is useful; \`mirror\` means voice input prefers a voice reply; \`always\` means the final reply is expected to be converted to voice, so keep it TTS-friendly.
19
20
  - Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
20
21
 
package/lib/routing.ts CHANGED
@@ -114,6 +114,7 @@ export interface TelegramInboundRouteRuntimeDeps<
114
114
  typeof PromptTemplates.getTelegramPromptTemplateCommands
115
115
  >[0];
116
116
  downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
117
+ resolveTimeLine?: (chatId: number) => string | null;
117
118
  getThinkingLevel: () => Model.ThinkingLevel;
118
119
  setThinkingLevel: (level: Model.ThinkingLevel) => void;
119
120
  persistScopedModelPatterns?: (
@@ -287,6 +288,7 @@ export function createTelegramInboundRouteRuntime<
287
288
  allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
288
289
  downloadFile: deps.downloadFile,
289
290
  processAttachments: deps.inboundHandlerRuntime.process,
291
+ resolveTimeLine: deps.resolveTimeLine,
290
292
 
291
293
  // Voice policy for the current turn. Missing config still behaves as manual,
292
294
  // but only explicit telegram.json voice.replyMode is shown in prompt context.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Telegram per-chat time injection runtime
3
+ * Zones: telegram inbound, prompt content
4
+ * Owns the formatted `[time]` line and the per-chat interval bookkeeping that decides when to emit it
5
+ */
6
+
7
+ import type { ResolvedTelegramTimeConfig } from "./config.ts";
8
+
9
+ export interface TimeInjectionRuntime {
10
+ resolveLine: (chatId: number, now?: Date) => string | null;
11
+ }
12
+
13
+ export interface TimeInjectionRuntimeDeps {
14
+ getConfig: () => ResolvedTelegramTimeConfig;
15
+ recordRuntimeEvent?: (
16
+ category: string,
17
+ error: unknown,
18
+ details?: Record<string, unknown>,
19
+ ) => void;
20
+ }
21
+
22
+ export function formatTelegramTimeInjectionLine(
23
+ now: Date,
24
+ timezone: string,
25
+ ): string {
26
+ const parts = new Intl.DateTimeFormat("en-CA", {
27
+ timeZone: timezone,
28
+ year: "numeric",
29
+ month: "2-digit",
30
+ day: "2-digit",
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ second: "2-digit",
34
+ hour12: false,
35
+ }).formatToParts(now);
36
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
37
+ parts.find((part) => part.type === type)?.value ?? "";
38
+ const year = get("year");
39
+ const month = get("month");
40
+ const day = get("day");
41
+ const hourRaw = get("hour");
42
+ const hour = hourRaw === "24" ? "00" : hourRaw;
43
+ const minute = get("minute");
44
+ const second = get("second");
45
+ return `${year}-${month}-${day} ${hour}:${minute}:${second} ${timezone}`;
46
+ }
47
+
48
+ export function createTimeInjectionRuntime(
49
+ deps: TimeInjectionRuntimeDeps,
50
+ ): TimeInjectionRuntime {
51
+ const lastInjectedAt = new Map<number, number>();
52
+ return {
53
+ resolveLine: (chatId, now = new Date()) => {
54
+ const config = deps.getConfig();
55
+ if (config.injectionMode === "off") return null;
56
+ let line: string;
57
+ try {
58
+ line = formatTelegramTimeInjectionLine(now, config.timezone);
59
+ } catch (error) {
60
+ deps.recordRuntimeEvent?.("time-injection", error, {
61
+ timezone: config.timezone,
62
+ });
63
+ return null;
64
+ }
65
+ if (config.injectionMode === "always") return line;
66
+ const previous = lastInjectedAt.get(chatId);
67
+ const nowMs = now.getTime();
68
+ if (
69
+ previous !== undefined &&
70
+ nowMs - previous < config.interval
71
+ ) {
72
+ return null;
73
+ }
74
+ lastInjectedAt.set(chatId, nowMs);
75
+ return line;
76
+ },
77
+ };
78
+ }
package/lib/turns.ts CHANGED
@@ -127,6 +127,7 @@ export function buildTelegramTurnPrompt(options: {
127
127
  promptFiles?: DownloadedTelegramTurnFile[];
128
128
  handlerOutputs?: string[];
129
129
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
130
+ timeLine?: string | null;
130
131
  voiceContext?: Record<string, string>;
131
132
  }): string {
132
133
  let prompt = options.telegramPrefix;
@@ -154,6 +155,9 @@ export function buildTelegramTurnPrompt(options: {
154
155
  if (options.voiceContext) {
155
156
  prompt = appendTelegramVoiceContext(prompt, options.voiceContext);
156
157
  }
158
+ if (options.timeLine) {
159
+ prompt = `${prompt}\n\n[time] ${options.timeLine}`;
160
+ }
157
161
  return prompt;
158
162
  }
159
163
 
@@ -330,6 +334,7 @@ export interface BuildTelegramPromptTurnOptions {
330
334
  files: DownloadedTelegramTurnFile[];
331
335
  promptFiles?: DownloadedTelegramTurnFile[];
332
336
  handlerOutputs?: string[];
337
+ timeLine?: string | null;
333
338
  readBinaryFile: (path: string) => Promise<Uint8Array>;
334
339
  inferImageMimeType: (path: string) => string | undefined;
335
340
  voiceReplyMode?: TelegramVoiceReplyMode;
@@ -355,6 +360,7 @@ export interface TelegramPromptTurnRuntimeBuilderDeps<
355
360
  promptFiles?: DownloadedTelegramTurnFile[];
356
361
  handlerOutputs?: string[];
357
362
  }>;
363
+ resolveTimeLine?: (chatId: number) => string | null;
358
364
  getVoiceReplyMode?: () => TelegramVoiceReplyMode;
359
365
  isVoiceReplyModeConfigured?: () => boolean;
360
366
  }
@@ -386,6 +392,11 @@ export function createTelegramPromptTurnRuntimeBuilder<
386
392
  );
387
393
  // Compute voice mode once and pass it to both the turn builder and the prompt contribution helper
388
394
  const voiceReplyMode = deps.getVoiceReplyMode?.();
395
+ const chatId = messages[0]?.chat.id;
396
+ const timeLine =
397
+ deps.resolveTimeLine && chatId !== undefined
398
+ ? deps.resolveTimeLine(chatId)
399
+ : null;
389
400
  return buildTelegramPromptTurnRuntime({
390
401
  telegramPrefix: TELEGRAM_PREFIX,
391
402
  messages,
@@ -396,6 +407,7 @@ export function createTelegramPromptTurnRuntimeBuilder<
396
407
  files,
397
408
  promptFiles: processed.promptFiles,
398
409
  handlerOutputs: processed.handlerOutputs,
410
+ timeLine,
399
411
  inferImageMimeType: guessMediaType,
400
412
  voiceReplyMode,
401
413
  voiceReplyModeConfigured: deps.isVoiceReplyModeConfigured?.(),
@@ -440,6 +452,7 @@ export async function buildTelegramPromptTurn(
440
452
  promptFiles: options.promptFiles,
441
453
  handlerOutputs: options.handlerOutputs,
442
454
  historyTurns: options.historyTurns,
455
+ timeLine: options.timeLine,
443
456
  voiceContext: showVoiceContext
444
457
  ? getTelegramVoicePromptContext(voiceReplyMode, hasVoiceFile)
445
458
  : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"