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