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.
- package/.env.example +116 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/agent/manager.js +88 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +49 -0
- package/dist/bot/commands/abort.js +121 -0
- package/dist/bot/commands/commands.js +480 -0
- package/dist/bot/commands/definitions.js +27 -0
- package/dist/bot/commands/help.js +10 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +70 -0
- package/dist/bot/commands/opencode-start.js +101 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +223 -0
- package/dist/bot/commands/rename.js +139 -0
- package/dist/bot/commands/sessions.js +351 -0
- package/dist/bot/commands/start.js +43 -0
- package/dist/bot/commands/status.js +95 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/commands/voice.js +145 -0
- package/dist/bot/handlers/agent.js +118 -0
- package/dist/bot/handlers/context.js +100 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +119 -0
- package/dist/bot/handlers/model.js +143 -0
- package/dist/bot/handlers/permission.js +235 -0
- package/dist/bot/handlers/prompt.js +240 -0
- package/dist/bot/handlers/question.js +390 -0
- package/dist/bot/handlers/tts.js +89 -0
- package/dist/bot/handlers/variant.js +138 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +977 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +95 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/streaming/response-streamer.js +286 -0
- package/dist/bot/streaming/tool-call-streamer.js +285 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/finalize-assistant-response.js +52 -0
- package/dist/bot/utils/keyboard.js +69 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
- package/dist/bot/utils/telegram-text.js +28 -0
- package/dist/bot/utils/thinking-message.js +8 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +97 -0
- package/dist/i18n/de.js +357 -0
- package/dist/i18n/en.js +357 -0
- package/dist/i18n/es.js +357 -0
- package/dist/i18n/fr.js +357 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/ru.js +357 -0
- package/dist/i18n/zh.js +357 -0
- package/dist/index.js +26 -0
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/cleanup.js +32 -0
- package/dist/interaction/guard.js +140 -0
- package/dist/interaction/manager.js +106 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +172 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/context-limit.js +57 -0
- package/dist/model/manager.js +259 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +140 -0
- package/dist/permission/manager.js +100 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/format.js +29 -0
- package/dist/pinned/manager.js +682 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +176 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +53 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +368 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +10 -0
- package/dist/settings/manager.js +158 -0
- package/dist/stt/client.js +97 -0
- package/dist/summary/aggregator.js +1136 -0
- package/dist/summary/formatter.js +491 -0
- package/dist/summary/subagent-formatter.js +63 -0
- package/dist/summary/tool-message-batcher.js +90 -0
- package/dist/tts/client.js +130 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +127 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- 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 {};
|