zardbot-telegram 1.0.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 (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,682 @@
1
+ import { logger } from "../utils/logger.js";
2
+ import { opencodeClient } from "../opencode/client.js";
3
+ import { getCurrentSession } from "../session/manager.js";
4
+ import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
5
+ import { getStoredModel } from "../model/manager.js";
6
+ import { getModelContextLimit } from "../model/context-limit.js";
7
+ import { t } from "../i18n/index.js";
8
+ import { DEFAULT_CONTEXT_LIMIT, formatContextLine, formatCostLine, formatModelDisplayName, } from "./format.js";
9
+ class PinnedMessageManager {
10
+ api = null;
11
+ chatId = null;
12
+ state = {
13
+ messageId: null,
14
+ chatId: null,
15
+ sessionId: null,
16
+ sessionTitle: t("pinned.default_session_title"),
17
+ projectName: "",
18
+ tokensUsed: 0,
19
+ tokensLimit: 0,
20
+ lastUpdated: 0,
21
+ changedFiles: [],
22
+ cost: 0,
23
+ };
24
+ contextLimit = null;
25
+ onKeyboardUpdateCallback;
26
+ updateDebounceTimer = null;
27
+ updateTask = null;
28
+ pendingUpdate = false;
29
+ pendingForceUpdate = false;
30
+ lastRenderedMessageText = null;
31
+ /**
32
+ * Initialize manager with bot API and chat ID
33
+ */
34
+ initialize(api, chatId) {
35
+ this.api = api;
36
+ this.chatId = chatId;
37
+ // Restore pinned message ID from settings
38
+ const savedMessageId = getPinnedMessageId();
39
+ if (savedMessageId) {
40
+ this.state.messageId = savedMessageId;
41
+ this.state.chatId = chatId;
42
+ }
43
+ }
44
+ /**
45
+ * Called when session changes - create new pinned message
46
+ */
47
+ async onSessionChange(sessionId, sessionTitle) {
48
+ logger.info(`[PinnedManager] Session changed: ${sessionId}, title: ${sessionTitle}`);
49
+ // Reset tokens for new session
50
+ this.state.tokensUsed = 0;
51
+ this.state.cost = 0;
52
+ // Update state
53
+ this.state.sessionId = sessionId;
54
+ this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
55
+ const project = getCurrentProject();
56
+ this.state.projectName =
57
+ project?.name || this.extractProjectName(project?.worktree) || t("pinned.unknown");
58
+ // Fetch context limit for current model
59
+ await this.fetchContextLimit();
60
+ // Trigger keyboard update callback with reset context (0 tokens)
61
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
62
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
63
+ }
64
+ // Reset changed files for new session
65
+ this.state.changedFiles = [];
66
+ this.lastRenderedMessageText = null;
67
+ this.pendingUpdate = false;
68
+ this.pendingForceUpdate = false;
69
+ // Unpin old message and create new one
70
+ await this.unpinOldMessage();
71
+ await this.createPinnedMessage();
72
+ // Load existing diffs from API (for session restoration)
73
+ await this.loadDiffsFromApi(sessionId);
74
+ }
75
+ /**
76
+ * Called when session title is updated (after first message)
77
+ */
78
+ async onSessionTitleUpdate(newTitle) {
79
+ if (this.state.sessionTitle !== newTitle && newTitle) {
80
+ logger.debug(`[PinnedManager] Session title updated: ${newTitle}`);
81
+ this.state.sessionTitle = newTitle;
82
+ await this.updatePinnedMessage();
83
+ }
84
+ }
85
+ /**
86
+ * Load context token usage from session history
87
+ */
88
+ async loadContextFromHistory(sessionId, directory) {
89
+ try {
90
+ logger.debug(`[PinnedManager] Loading context from history for session: ${sessionId}`);
91
+ const { data: messagesData, error } = await opencodeClient.session.messages({
92
+ sessionID: sessionId,
93
+ directory,
94
+ });
95
+ if (error || !messagesData) {
96
+ logger.warn("[PinnedManager] Failed to load session history:", error);
97
+ return;
98
+ }
99
+ // Get the maximum context size and total cost from session history
100
+ // Context = input + cache.read (cache.read contains previously cached context)
101
+ let maxContextSize = 0;
102
+ let totalCost = 0;
103
+ logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);
104
+ messagesData.forEach(({ info }) => {
105
+ if (info.role === "assistant") {
106
+ const assistantInfo = info;
107
+ // Skip summary messages (technical, not real agent responses)
108
+ if (assistantInfo.summary) {
109
+ logger.debug(`[PinnedManager] Skipping summary message`);
110
+ return;
111
+ }
112
+ const input = assistantInfo.tokens?.input || 0;
113
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0;
114
+ const contextSize = input + cacheRead;
115
+ const cost = assistantInfo.cost || 0;
116
+ logger.debug(`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}, cost=$${cost.toFixed(2)}`);
117
+ // Keep track of maximum context size (peak usage in session)
118
+ if (contextSize > maxContextSize) {
119
+ maxContextSize = contextSize;
120
+ }
121
+ // Accumulate total session cost
122
+ totalCost += cost;
123
+ }
124
+ });
125
+ this.state.tokensUsed = maxContextSize;
126
+ this.state.cost = totalCost;
127
+ this.state.sessionId = sessionId;
128
+ logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens, cost: $${this.state.cost.toFixed(2)}`);
129
+ await this.updatePinnedMessage();
130
+ }
131
+ catch (err) {
132
+ logger.error("[PinnedManager] Error loading context from history:", err);
133
+ }
134
+ }
135
+ /**
136
+ * Called when session is compacted - reload context from history
137
+ */
138
+ async onSessionCompacted(sessionId, directory) {
139
+ logger.info(`[PinnedManager] Session compacted, reloading context: ${sessionId}`);
140
+ // Reload context from updated history (after compaction)
141
+ await this.loadContextFromHistory(sessionId, directory);
142
+ }
143
+ /**
144
+ * Called when assistant message completes with token info
145
+ */
146
+ async onMessageComplete(tokens) {
147
+ // Ensure context limit is available even if session was restored
148
+ // without a fresh onSessionChange call (for example after /abort + continue).
149
+ if (this.getContextLimit() === 0) {
150
+ await this.fetchContextLimit();
151
+ }
152
+ // Context = input + cache.read (cache.read contains previously cached context)
153
+ // This represents the actual context window usage
154
+ this.state.tokensUsed = tokens.input + tokens.cacheRead;
155
+ logger.debug(`[PinnedManager] Tokens updated: ${this.state.tokensUsed}/${this.state.tokensLimit}`);
156
+ // Also fetch latest session title (it may have changed after first message)
157
+ await this.refreshSessionTitle();
158
+ await this.updatePinnedMessage();
159
+ }
160
+ /**
161
+ * Update tokens in memory without triggering an API call.
162
+ * Used for intermediate (non-completed) message.updated events
163
+ * to keep pinned state in sync with keyboardManager.
164
+ */
165
+ updateTokensSilent(tokens) {
166
+ this.state.tokensUsed = tokens.input + tokens.cacheRead;
167
+ logger.debug(`[PinnedManager] Tokens updated (silent): ${this.state.tokensUsed}/${this.state.tokensLimit}`);
168
+ }
169
+ /**
170
+ * Refresh the pinned message with current in-memory state.
171
+ * Used at thinking time to push accumulated silent updates to Telegram.
172
+ */
173
+ async refresh() {
174
+ await this.updatePinnedMessage(true);
175
+ }
176
+ /**
177
+ * Called when cost info is received from SSE events
178
+ */
179
+ async onCostUpdate(cost) {
180
+ if (!Number.isFinite(cost) || cost === 0) {
181
+ logger.debug("[PinnedManager] Ignoring non-impacting cost update");
182
+ return;
183
+ }
184
+ const currentCost = this.state.cost || 0;
185
+ this.state.cost = currentCost + cost;
186
+ logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
187
+ await this.updatePinnedMessage();
188
+ }
189
+ /**
190
+ * Set callback for keyboard updates when context changes
191
+ */
192
+ setOnKeyboardUpdate(callback) {
193
+ this.onKeyboardUpdateCallback = callback;
194
+ logger.debug("[PinnedManager] Keyboard update callback registered");
195
+ // Fire immediately with current state to fix race condition:
196
+ // onSessionChange may have already run before this callback was registered.
197
+ const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0;
198
+ if (limit > 0) {
199
+ callback(this.state.tokensUsed, limit);
200
+ }
201
+ }
202
+ /**
203
+ * Get current context information
204
+ */
205
+ getContextInfo() {
206
+ // Use cached contextLimit if tokensLimit is not set yet
207
+ const limit = this.state.tokensLimit > 0 ? this.state.tokensLimit : this.contextLimit || 0;
208
+ if (limit === 0) {
209
+ return null;
210
+ }
211
+ return {
212
+ tokensUsed: this.state.tokensUsed,
213
+ tokensLimit: limit,
214
+ };
215
+ }
216
+ /**
217
+ * Get context limit (for keyboard display when no session)
218
+ * Returns cached limit or 0 if not available
219
+ */
220
+ getContextLimit() {
221
+ return this.contextLimit || this.state.tokensLimit || 0;
222
+ }
223
+ /**
224
+ * Refresh context limit for current model (call after model change)
225
+ */
226
+ async refreshContextLimit() {
227
+ await this.fetchContextLimit();
228
+ }
229
+ /**
230
+ * Called when session.diff SSE event is received.
231
+ * Only overwrites if non-empty (API may return empty while tool events collected data).
232
+ */
233
+ async onSessionDiff(diffs) {
234
+ if (diffs.length === 0 && this.state.changedFiles.length > 0) {
235
+ logger.debug("[PinnedManager] Ignoring empty session.diff, keeping tool-collected data");
236
+ return;
237
+ }
238
+ if (this.areFileDiffsEqual(this.state.changedFiles, diffs)) {
239
+ logger.debug("[PinnedManager] Ignoring unchanged session.diff");
240
+ return;
241
+ }
242
+ this.state.changedFiles = diffs;
243
+ logger.debug(`[PinnedManager] Session diff updated: ${diffs.length} files`);
244
+ await this.updatePinnedMessage();
245
+ }
246
+ /**
247
+ * Called when a single file is changed (from tool events: edit/write)
248
+ */
249
+ addFileChange(change) {
250
+ const existing = this.state.changedFiles.find((f) => f.file === change.file);
251
+ if (existing) {
252
+ existing.additions += change.additions;
253
+ existing.deletions += change.deletions;
254
+ }
255
+ else {
256
+ this.state.changedFiles.push(change);
257
+ }
258
+ logger.debug(`[PinnedManager] File change added: ${change.file} (+${change.additions} -${change.deletions}), total: ${this.state.changedFiles.length}`);
259
+ // Schedule debounced update (avoid spamming Telegram API on rapid tool events)
260
+ this.scheduleDebouncedUpdate();
261
+ }
262
+ scheduleDebouncedUpdate() {
263
+ if (this.updateDebounceTimer) {
264
+ clearTimeout(this.updateDebounceTimer);
265
+ }
266
+ this.updateDebounceTimer = setTimeout(() => {
267
+ this.updateDebounceTimer = null;
268
+ void this.updatePinnedMessage();
269
+ }, 1000);
270
+ }
271
+ /**
272
+ * Load file diffs from API for current session.
273
+ * Tries session.diff() first, falls back to parsing session.messages() tool parts.
274
+ */
275
+ async loadDiffsFromApi(sessionId) {
276
+ try {
277
+ const project = getCurrentProject();
278
+ if (!project) {
279
+ logger.debug("[PinnedManager] loadDiffsFromApi: no project");
280
+ return;
281
+ }
282
+ logger.debug(`[PinnedManager] loadDiffsFromApi: trying session.diff() for ${sessionId}`);
283
+ // Try session.diff() API first
284
+ const { data, error } = await opencodeClient.session.diff({
285
+ sessionID: sessionId,
286
+ directory: project.worktree,
287
+ });
288
+ logger.debug(`[PinnedManager] session.diff() result: error=${!!error}, data.length=${data?.length ?? 0}`);
289
+ if (!error && data && data.length > 0) {
290
+ this.state.changedFiles = data.map((d) => ({
291
+ file: d.file,
292
+ additions: d.additions,
293
+ deletions: d.deletions,
294
+ }));
295
+ logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from session.diff()`);
296
+ await this.updatePinnedMessage();
297
+ return;
298
+ }
299
+ // Fallback: parse tool parts from session messages
300
+ logger.debug("[PinnedManager] session.diff() empty, trying loadDiffsFromMessages()");
301
+ await this.loadDiffsFromMessages(sessionId, project.worktree);
302
+ }
303
+ catch (err) {
304
+ logger.debug("[PinnedManager] Could not load diffs from API:", err);
305
+ }
306
+ }
307
+ /**
308
+ * Fallback: extract file changes from session message tool parts
309
+ */
310
+ async loadDiffsFromMessages(sessionId, directory) {
311
+ try {
312
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: fetching messages for ${sessionId}`);
313
+ const { data: messagesData, error } = await opencodeClient.session.messages({
314
+ sessionID: sessionId,
315
+ directory,
316
+ });
317
+ if (error || !messagesData) {
318
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: error or no data`);
319
+ return;
320
+ }
321
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
322
+ const filesMap = new Map();
323
+ let toolCount = 0;
324
+ let fileToolCount = 0;
325
+ for (const { parts } of messagesData) {
326
+ for (const part of parts) {
327
+ if (part.type !== "tool")
328
+ continue;
329
+ toolCount++;
330
+ const toolPart = part;
331
+ if (toolPart.state.status !== "completed")
332
+ continue;
333
+ if (toolPart.tool === "edit" ||
334
+ toolPart.tool === "write" ||
335
+ toolPart.tool === "apply_patch") {
336
+ fileToolCount++;
337
+ }
338
+ if ((toolPart.tool === "edit" || toolPart.tool === "apply_patch") &&
339
+ toolPart.state.metadata &&
340
+ "filediff" in toolPart.state.metadata) {
341
+ const filediff = toolPart.state.metadata.filediff;
342
+ if (filediff.file) {
343
+ const existing = filesMap.get(filediff.file);
344
+ if (existing) {
345
+ existing.additions += filediff.additions || 0;
346
+ existing.deletions += filediff.deletions || 0;
347
+ }
348
+ else {
349
+ filesMap.set(filediff.file, {
350
+ file: filediff.file,
351
+ additions: filediff.additions || 0,
352
+ deletions: filediff.deletions || 0,
353
+ });
354
+ }
355
+ }
356
+ }
357
+ else if (toolPart.tool === "write" &&
358
+ toolPart.state.input &&
359
+ "filePath" in toolPart.state.input &&
360
+ "content" in toolPart.state.input) {
361
+ const filePath = toolPart.state.input.filePath;
362
+ const content = toolPart.state.input.content;
363
+ const lines = content.split("\n").length;
364
+ const existing = filesMap.get(filePath);
365
+ if (existing) {
366
+ existing.additions += lines;
367
+ }
368
+ else {
369
+ filesMap.set(filePath, {
370
+ file: filePath,
371
+ additions: lines,
372
+ deletions: 0,
373
+ });
374
+ }
375
+ }
376
+ }
377
+ }
378
+ logger.debug(`[PinnedManager] loadDiffsFromMessages: found ${toolCount} tool parts, ${fileToolCount} file tools`);
379
+ if (filesMap.size > 0) {
380
+ this.state.changedFiles = Array.from(filesMap.values());
381
+ logger.info(`[PinnedManager] Loaded ${this.state.changedFiles.length} file diffs from messages`);
382
+ await this.updatePinnedMessage();
383
+ }
384
+ else {
385
+ logger.debug("[PinnedManager] loadDiffsFromMessages: no file changes found");
386
+ }
387
+ }
388
+ catch (err) {
389
+ logger.debug("[PinnedManager] Could not load diffs from messages:", err);
390
+ }
391
+ }
392
+ /**
393
+ * Refresh session title from API
394
+ */
395
+ async refreshSessionTitle() {
396
+ const session = getCurrentSession();
397
+ const project = getCurrentProject();
398
+ if (!session || !project) {
399
+ return;
400
+ }
401
+ try {
402
+ const { data: sessionData } = await opencodeClient.session.get({
403
+ sessionID: session.id,
404
+ directory: project.worktree,
405
+ });
406
+ if (sessionData && sessionData.title !== this.state.sessionTitle) {
407
+ this.state.sessionTitle = sessionData.title;
408
+ logger.debug(`[PinnedManager] Session title refreshed: ${sessionData.title}`);
409
+ }
410
+ }
411
+ catch (err) {
412
+ logger.debug("[PinnedManager] Could not refresh session title:", err);
413
+ }
414
+ }
415
+ /**
416
+ * Extract project name from worktree path
417
+ */
418
+ extractProjectName(worktree) {
419
+ if (!worktree)
420
+ return "";
421
+ // Get last part of path
422
+ const parts = worktree.replace(/\\/g, "/").split("/");
423
+ return parts[parts.length - 1] || "";
424
+ }
425
+ /**
426
+ * Make file path relative to project worktree
427
+ */
428
+ makeRelativePath(filePath) {
429
+ const normalized = filePath.replace(/\\/g, "/");
430
+ const project = getCurrentProject();
431
+ if (project?.worktree) {
432
+ const worktree = project.worktree.replace(/\\/g, "/");
433
+ if (normalized.startsWith(worktree)) {
434
+ // Remove worktree prefix and leading slash
435
+ let relative = normalized.slice(worktree.length);
436
+ if (relative.startsWith("/")) {
437
+ relative = relative.slice(1);
438
+ }
439
+ return relative || normalized;
440
+ }
441
+ }
442
+ // Fallback: just show last 3 segments if path is still absolute
443
+ const segments = normalized.split("/");
444
+ if (segments.length <= 3)
445
+ return normalized;
446
+ return ".../" + segments.slice(-3).join("/");
447
+ }
448
+ areFileDiffsEqual(current, next) {
449
+ if (current.length !== next.length) {
450
+ return false;
451
+ }
452
+ for (let index = 0; index < current.length; index++) {
453
+ const left = current[index];
454
+ const right = next[index];
455
+ if (left.file !== right.file ||
456
+ left.additions !== right.additions ||
457
+ left.deletions !== right.deletions) {
458
+ return false;
459
+ }
460
+ }
461
+ return true;
462
+ }
463
+ /**
464
+ * Fetch context limit from current model configuration
465
+ */
466
+ async fetchContextLimit() {
467
+ try {
468
+ const model = getStoredModel();
469
+ this.contextLimit = await getModelContextLimit(model.providerID, model.modelID);
470
+ this.state.tokensLimit = this.contextLimit;
471
+ logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
472
+ }
473
+ catch (err) {
474
+ logger.error("[PinnedManager] Error fetching context limit:", err);
475
+ this.contextLimit = DEFAULT_CONTEXT_LIMIT;
476
+ this.state.tokensLimit = this.contextLimit;
477
+ }
478
+ }
479
+ /**
480
+ * Format the pinned message text
481
+ */
482
+ formatMessage() {
483
+ const currentModel = getStoredModel();
484
+ const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
485
+ const lines = [
486
+ `${this.state.sessionTitle}`,
487
+ t("pinned.line.project", { project: this.state.projectName }),
488
+ t("pinned.line.model", { model: modelName }),
489
+ formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
490
+ ];
491
+ if (this.state.cost !== undefined && this.state.cost !== null) {
492
+ lines.push(formatCostLine(this.state.cost));
493
+ }
494
+ if (this.state.changedFiles.length > 0) {
495
+ const maxFiles = 10;
496
+ const total = this.state.changedFiles.length;
497
+ const filesToShow = this.state.changedFiles.slice(0, maxFiles);
498
+ lines.push("");
499
+ lines.push(t("pinned.files.title", { count: total }));
500
+ for (const f of filesToShow) {
501
+ const relativePath = this.makeRelativePath(f.file);
502
+ const parts = [];
503
+ if (f.additions > 0)
504
+ parts.push(`+${f.additions}`);
505
+ if (f.deletions > 0)
506
+ parts.push(`-${f.deletions}`);
507
+ const diffStr = parts.length > 0 ? ` (${parts.join(" ")})` : "";
508
+ lines.push(t("pinned.files.item", { path: relativePath, diff: diffStr }));
509
+ }
510
+ if (total > maxFiles) {
511
+ lines.push(t("pinned.files.more", { count: total - maxFiles }));
512
+ }
513
+ }
514
+ return lines.join("\n");
515
+ }
516
+ /**
517
+ * Create and pin a new status message
518
+ */
519
+ async createPinnedMessage() {
520
+ if (!this.api || !this.chatId) {
521
+ logger.warn("[PinnedManager] API or chatId not initialized");
522
+ return;
523
+ }
524
+ try {
525
+ const text = this.formatMessage();
526
+ // Send new message
527
+ const sentMessage = await this.api.sendMessage(this.chatId, text);
528
+ this.state.messageId = sentMessage.message_id;
529
+ this.state.chatId = this.chatId;
530
+ this.state.lastUpdated = Date.now();
531
+ this.lastRenderedMessageText = text;
532
+ // Save to settings for persistence
533
+ setPinnedMessageId(sentMessage.message_id);
534
+ // Pin the message (silently)
535
+ await this.api.pinChatMessage(this.chatId, sentMessage.message_id, {
536
+ disable_notification: true,
537
+ });
538
+ logger.info(`[PinnedManager] Created and pinned message: ${sentMessage.message_id}`);
539
+ }
540
+ catch (err) {
541
+ logger.error("[PinnedManager] Error creating pinned message:", err);
542
+ }
543
+ }
544
+ /**
545
+ * Update existing pinned message text
546
+ */
547
+ async updatePinnedMessage(forceUpdate = false) {
548
+ if (!this.api || !this.chatId || !this.state.messageId) {
549
+ return;
550
+ }
551
+ this.pendingUpdate = true;
552
+ if (forceUpdate) {
553
+ this.pendingForceUpdate = true;
554
+ }
555
+ if (this.updateTask) {
556
+ await this.updateTask;
557
+ return;
558
+ }
559
+ this.updateTask = this.flushPendingPinnedUpdates().finally(() => {
560
+ this.updateTask = null;
561
+ });
562
+ await this.updateTask;
563
+ }
564
+ async flushPendingPinnedUpdates() {
565
+ while (this.pendingUpdate) {
566
+ this.pendingUpdate = false;
567
+ const shouldForceUpdate = this.pendingForceUpdate;
568
+ this.pendingForceUpdate = false;
569
+ if (!this.api || !this.chatId || !this.state.messageId) {
570
+ return;
571
+ }
572
+ const text = this.formatMessage();
573
+ if (!shouldForceUpdate && text === this.lastRenderedMessageText) {
574
+ logger.debug("[PinnedManager] Skipping pinned update: message content unchanged");
575
+ continue;
576
+ }
577
+ try {
578
+ await this.api.editMessageText(this.chatId, this.state.messageId, text);
579
+ this.state.lastUpdated = Date.now();
580
+ this.lastRenderedMessageText = text;
581
+ logger.debug(`[PinnedManager] Updated pinned message: ${this.state.messageId}`);
582
+ // Trigger keyboard update callback
583
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
584
+ setImmediate(() => {
585
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
586
+ });
587
+ }
588
+ }
589
+ catch (err) {
590
+ const errorMessage = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
591
+ // Handle "message is not modified" error silently
592
+ if (errorMessage.includes("message is not modified")) {
593
+ this.lastRenderedMessageText = text;
594
+ continue;
595
+ }
596
+ // Handle "message to edit not found" - recreate
597
+ if (errorMessage.includes("message to edit not found")) {
598
+ logger.warn("[PinnedManager] Pinned message was deleted, recreating...");
599
+ this.state.messageId = null;
600
+ this.lastRenderedMessageText = null;
601
+ this.pendingForceUpdate = false;
602
+ clearPinnedMessageId();
603
+ await this.createPinnedMessage();
604
+ continue;
605
+ }
606
+ logger.error("[PinnedManager] Error updating pinned message:", err);
607
+ }
608
+ }
609
+ }
610
+ /**
611
+ * Unpin old message before creating new one
612
+ */
613
+ async unpinOldMessage() {
614
+ if (!this.api || !this.chatId) {
615
+ return;
616
+ }
617
+ try {
618
+ // Unpin all messages (ensures clean state)
619
+ await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
620
+ this.state.messageId = null;
621
+ this.lastRenderedMessageText = null;
622
+ this.pendingUpdate = false;
623
+ this.pendingForceUpdate = false;
624
+ clearPinnedMessageId();
625
+ logger.debug("[PinnedManager] Unpinned old messages");
626
+ }
627
+ catch (err) {
628
+ logger.error("[PinnedManager] Error unpinning messages:", err);
629
+ }
630
+ }
631
+ /**
632
+ * Get current state (for debugging/status)
633
+ */
634
+ getState() {
635
+ return { ...this.state };
636
+ }
637
+ /**
638
+ * Check if manager is initialized
639
+ */
640
+ isInitialized() {
641
+ return this.api !== null && this.chatId !== null;
642
+ }
643
+ /**
644
+ * Clear pinned message (when switching projects)
645
+ */
646
+ async clear() {
647
+ if (!this.api || !this.chatId) {
648
+ // Just reset state if not initialized
649
+ this.state.messageId = null;
650
+ this.state.sessionId = null;
651
+ this.state.tokensUsed = 0;
652
+ this.state.tokensLimit = 0;
653
+ this.state.changedFiles = [];
654
+ this.lastRenderedMessageText = null;
655
+ this.pendingUpdate = false;
656
+ this.pendingForceUpdate = false;
657
+ clearPinnedMessageId();
658
+ return;
659
+ }
660
+ try {
661
+ // Unpin all messages
662
+ await this.api.unpinAllChatMessages(this.chatId).catch(() => { });
663
+ // Reset state
664
+ this.state.messageId = null;
665
+ this.state.sessionId = null;
666
+ this.state.sessionTitle = t("pinned.default_session_title");
667
+ this.state.projectName = "";
668
+ this.state.tokensUsed = 0;
669
+ this.state.tokensLimit = 0;
670
+ this.state.changedFiles = [];
671
+ this.lastRenderedMessageText = null;
672
+ this.pendingUpdate = false;
673
+ this.pendingForceUpdate = false;
674
+ clearPinnedMessageId();
675
+ logger.info("[PinnedManager] Cleared pinned message state");
676
+ }
677
+ catch (err) {
678
+ logger.error("[PinnedManager] Error clearing pinned message:", err);
679
+ }
680
+ }
681
+ }
682
+ export const pinnedMessageManager = new PinnedMessageManager();
@@ -0,0 +1 @@
1
+ export {};