@grinev/opencode-telegram-bot 0.1.1 → 0.2.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/dist/app/start-bot-app.js +2 -0
- package/dist/bot/commands/new.js +2 -0
- package/dist/bot/commands/projects.js +6 -2
- package/dist/bot/handlers/permission.js +5 -2
- package/dist/bot/handlers/question.js +5 -2
- package/dist/bot/index.js +55 -16
- package/dist/bot/message-patterns.js +3 -0
- package/dist/i18n/en.js +2 -1
- package/dist/i18n/ru.js +2 -1
- package/dist/opencode/events.js +78 -17
- package/dist/project/manager.js +61 -1
- package/dist/session/cache-manager.js +400 -0
- package/dist/settings/manager.js +42 -18
- package/dist/utils/error-format.js +29 -0
- package/package.json +3 -1
|
@@ -3,6 +3,7 @@ import { createBot } from "../bot/index.js";
|
|
|
3
3
|
import { config } from "../config.js";
|
|
4
4
|
import { loadSettings } from "../settings/manager.js";
|
|
5
5
|
import { processManager } from "../process/manager.js";
|
|
6
|
+
import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
6
7
|
import { getRuntimeMode } from "../runtime/mode.js";
|
|
7
8
|
import { logger } from "../utils/logger.js";
|
|
8
9
|
async function getBotVersion() {
|
|
@@ -25,6 +26,7 @@ export async function startBotApp() {
|
|
|
25
26
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
26
27
|
await loadSettings();
|
|
27
28
|
await processManager.initialize();
|
|
29
|
+
await warmupSessionDirectoryCache();
|
|
28
30
|
const bot = createBot();
|
|
29
31
|
const webhookInfo = await bot.api.getWebhookInfo();
|
|
30
32
|
if (webhookInfo.url) {
|
package/dist/bot/commands/new.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { opencodeClient } from "../../opencode/client.js";
|
|
2
2
|
import { setCurrentSession } from "../../session/manager.js";
|
|
3
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
3
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
4
5
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
5
6
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
@@ -30,6 +31,7 @@ export async function newCommand(ctx) {
|
|
|
30
31
|
directory: currentProject.worktree,
|
|
31
32
|
};
|
|
32
33
|
setCurrentSession(sessionInfo);
|
|
34
|
+
await ingestSessionInfoForCache(session);
|
|
33
35
|
// Initialize pinned message manager and create pinned message
|
|
34
36
|
if (!pinnedMessageManager.isInitialized()) {
|
|
35
37
|
pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
2
|
import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
|
|
3
3
|
import { getProjects } from "../../project/manager.js";
|
|
4
|
+
import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
|
|
4
5
|
import { clearSession } from "../../session/manager.js";
|
|
5
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
@@ -12,6 +13,7 @@ import { createMainKeyboard } from "../utils/keyboard.js";
|
|
|
12
13
|
import { logger } from "../../utils/logger.js";
|
|
13
14
|
import { t } from "../../i18n/index.js";
|
|
14
15
|
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
16
|
+
const MAX_PROJECTS_TO_SHOW = 10;
|
|
15
17
|
function formatProjectButtonLabel(label, isActive) {
|
|
16
18
|
const prefix = isActive ? "✅ " : "";
|
|
17
19
|
const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
|
|
@@ -22,14 +24,16 @@ function formatProjectButtonLabel(label, isActive) {
|
|
|
22
24
|
}
|
|
23
25
|
export async function projectsCommand(ctx) {
|
|
24
26
|
try {
|
|
27
|
+
await syncSessionDirectoryCache();
|
|
25
28
|
const projects = await getProjects();
|
|
26
|
-
|
|
29
|
+
const projectsToShow = projects.slice(0, MAX_PROJECTS_TO_SHOW);
|
|
30
|
+
if (projectsToShow.length === 0) {
|
|
27
31
|
await ctx.reply(t("projects.empty"));
|
|
28
32
|
return;
|
|
29
33
|
}
|
|
30
34
|
const keyboard = new InlineKeyboard();
|
|
31
35
|
const currentProject = getCurrentProject();
|
|
32
|
-
|
|
36
|
+
projectsToShow.forEach((project, index) => {
|
|
33
37
|
const isActive = currentProject &&
|
|
34
38
|
(project.id === currentProject.id || project.worktree === currentProject.worktree);
|
|
35
39
|
const label = project.name
|
|
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
|
|
|
2
2
|
import { permissionManager } from "../../permission/manager.js";
|
|
3
3
|
import { opencodeClient } from "../../opencode/client.js";
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
|
+
import { getCurrentSession } from "../../session/manager.js";
|
|
5
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
7
8
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
@@ -69,8 +70,10 @@ export async function handlePermissionCallback(ctx) {
|
|
|
69
70
|
async function handlePermissionReply(ctx, reply) {
|
|
70
71
|
const requestID = permissionManager.getRequestID();
|
|
71
72
|
const currentProject = getCurrentProject();
|
|
73
|
+
const currentSession = getCurrentSession();
|
|
72
74
|
const chatId = ctx.chat?.id;
|
|
73
|
-
|
|
75
|
+
const directory = currentSession?.directory ?? currentProject?.worktree;
|
|
76
|
+
if (!requestID || !directory || !chatId) {
|
|
74
77
|
await ctx.answerCallbackQuery({
|
|
75
78
|
text: t("permission.no_active_request_callback"),
|
|
76
79
|
show_alert: true,
|
|
@@ -94,7 +97,7 @@ async function handlePermissionReply(ctx, reply) {
|
|
|
94
97
|
taskName: "permission.reply",
|
|
95
98
|
task: () => opencodeClient.permission.reply({
|
|
96
99
|
requestID,
|
|
97
|
-
directory
|
|
100
|
+
directory,
|
|
98
101
|
reply,
|
|
99
102
|
}),
|
|
100
103
|
onSuccess: ({ error }) => {
|
|
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
|
|
|
2
2
|
import { questionManager } from "../../question/manager.js";
|
|
3
3
|
import { opencodeClient } from "../../opencode/client.js";
|
|
4
4
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
|
+
import { getCurrentSession } from "../../session/manager.js";
|
|
5
6
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
7
8
|
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
@@ -194,9 +195,11 @@ async function showPollSummary(bot, chatId) {
|
|
|
194
195
|
}
|
|
195
196
|
async function sendAllAnswersToAgent(bot, chatId) {
|
|
196
197
|
const currentProject = getCurrentProject();
|
|
198
|
+
const currentSession = getCurrentSession();
|
|
197
199
|
const requestID = questionManager.getRequestID();
|
|
198
200
|
const totalQuestions = questionManager.getTotalQuestions();
|
|
199
|
-
|
|
201
|
+
const directory = currentSession?.directory ?? currentProject?.worktree;
|
|
202
|
+
if (!directory) {
|
|
200
203
|
logger.error("[QuestionHandler] No project for sending answers");
|
|
201
204
|
await bot.sendMessage(chatId, t("question.no_active_project"));
|
|
202
205
|
return;
|
|
@@ -233,7 +236,7 @@ async function sendAllAnswersToAgent(bot, chatId) {
|
|
|
233
236
|
taskName: "question.reply",
|
|
234
237
|
task: () => opencodeClient.question.reply({
|
|
235
238
|
requestID,
|
|
236
|
-
directory
|
|
239
|
+
directory,
|
|
237
240
|
answers: allAnswers,
|
|
238
241
|
}),
|
|
239
242
|
onSuccess: ({ error }) => {
|
package/dist/bot/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { BOT_COMMANDS } from "./commands/definitions.js";
|
|
|
8
8
|
import { startCommand } from "./commands/start.js";
|
|
9
9
|
import { helpCommand } from "./commands/help.js";
|
|
10
10
|
import { statusCommand } from "./commands/status.js";
|
|
11
|
+
import { MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN } from "./message-patterns.js";
|
|
11
12
|
import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
|
|
12
13
|
import { newCommand } from "./commands/new.js";
|
|
13
14
|
import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
|
|
@@ -23,12 +24,14 @@ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
|
|
|
23
24
|
import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
|
|
24
25
|
import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
|
|
25
26
|
import { questionManager } from "../question/manager.js";
|
|
27
|
+
import { permissionManager } from "../permission/manager.js";
|
|
26
28
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
27
|
-
import { subscribeToEvents } from "../opencode/events.js";
|
|
29
|
+
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
28
30
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
29
31
|
import { formatSummary, formatToolInfo } from "../summary/formatter.js";
|
|
30
32
|
import { opencodeClient } from "../opencode/client.js";
|
|
31
|
-
import { getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
33
|
+
import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
34
|
+
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
32
35
|
import { getCurrentProject } from "../settings/manager.js";
|
|
33
36
|
import { getStoredAgent } from "../agent/manager.js";
|
|
34
37
|
import { getStoredModel } from "../model/manager.js";
|
|
@@ -36,6 +39,7 @@ import { formatVariantForButton } from "../variant/manager.js";
|
|
|
36
39
|
import { createMainKeyboard } from "./utils/keyboard.js";
|
|
37
40
|
import { logger } from "../utils/logger.js";
|
|
38
41
|
import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
42
|
+
import { formatErrorDetails } from "../utils/error-format.js";
|
|
39
43
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
40
44
|
import { t } from "../i18n/index.js";
|
|
41
45
|
let botInstance = null;
|
|
@@ -66,10 +70,9 @@ async function ensureCommandsInitialized(ctx, next) {
|
|
|
66
70
|
}
|
|
67
71
|
await next();
|
|
68
72
|
}
|
|
69
|
-
async function ensureEventSubscription() {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
logger.error("No current project found for event subscription");
|
|
73
|
+
async function ensureEventSubscription(directory) {
|
|
74
|
+
if (!directory) {
|
|
75
|
+
logger.error("No directory found for event subscription");
|
|
73
76
|
return;
|
|
74
77
|
}
|
|
75
78
|
summaryAggregator.setOnComplete(async (sessionId, messageText) => {
|
|
@@ -254,8 +257,17 @@ async function ensureEventSubscription() {
|
|
|
254
257
|
logger.error("[Bot] Error updating keyboard context:", err);
|
|
255
258
|
}
|
|
256
259
|
});
|
|
257
|
-
logger.info(`[Bot] Subscribing to OpenCode events for project: ${
|
|
258
|
-
subscribeToEvents(
|
|
260
|
+
logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
|
|
261
|
+
subscribeToEvents(directory, (event) => {
|
|
262
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
263
|
+
const info = event.properties.info;
|
|
264
|
+
if (info?.directory) {
|
|
265
|
+
safeBackgroundTask({
|
|
266
|
+
taskName: `session.cache.${event.type}`,
|
|
267
|
+
task: () => ingestSessionInfoForCache(info),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
259
271
|
summaryAggregator.processEvent(event);
|
|
260
272
|
}).catch((err) => {
|
|
261
273
|
logger.error("Failed to subscribe to events:", err);
|
|
@@ -280,6 +292,23 @@ async function isSessionBusy(sessionId, directory) {
|
|
|
280
292
|
return false;
|
|
281
293
|
}
|
|
282
294
|
}
|
|
295
|
+
async function resetMismatchedSessionContext() {
|
|
296
|
+
stopEventListening();
|
|
297
|
+
summaryAggregator.clear();
|
|
298
|
+
questionManager.clear();
|
|
299
|
+
permissionManager.clear();
|
|
300
|
+
clearSession();
|
|
301
|
+
keyboardManager.clearContext();
|
|
302
|
+
if (!pinnedMessageManager.isInitialized()) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await pinnedMessageManager.clear();
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
logger.error("[Bot] Failed to clear pinned message during session reset:", err);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
283
312
|
export function createBot() {
|
|
284
313
|
const bot = new Bot(config.telegram.token);
|
|
285
314
|
// Heartbeat for diagnostics: verify the event loop is not blocked
|
|
@@ -369,8 +398,8 @@ export function createBot() {
|
|
|
369
398
|
}
|
|
370
399
|
});
|
|
371
400
|
// Handle Reply Keyboard button press (model selector)
|
|
372
|
-
//
|
|
373
|
-
bot.hears(
|
|
401
|
+
// Model button text is produced by formatModelForButton() and always starts with "🤖 ".
|
|
402
|
+
bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
374
403
|
logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
|
|
375
404
|
try {
|
|
376
405
|
await showModelSelectionMenu(ctx);
|
|
@@ -392,7 +421,8 @@ export function createBot() {
|
|
|
392
421
|
}
|
|
393
422
|
});
|
|
394
423
|
// Handle Reply Keyboard button press (variant selector)
|
|
395
|
-
|
|
424
|
+
// Keep support for both legacy "💭" and current "💡" prefix.
|
|
425
|
+
bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
396
426
|
logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
|
|
397
427
|
try {
|
|
398
428
|
await showVariantSelectionMenu(ctx);
|
|
@@ -450,7 +480,6 @@ export function createBot() {
|
|
|
450
480
|
await ctx.reply(t("bot.project_not_selected"));
|
|
451
481
|
return;
|
|
452
482
|
}
|
|
453
|
-
await ensureEventSubscription();
|
|
454
483
|
botInstance = bot;
|
|
455
484
|
chatIdInstance = ctx.chat.id;
|
|
456
485
|
// Initialize pinned message manager if not already
|
|
@@ -460,6 +489,12 @@ export function createBot() {
|
|
|
460
489
|
// Initialize keyboard manager if not already
|
|
461
490
|
keyboardManager.initialize(bot.api, ctx.chat.id);
|
|
462
491
|
let currentSession = getCurrentSession();
|
|
492
|
+
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
493
|
+
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
494
|
+
await resetMismatchedSessionContext();
|
|
495
|
+
await ctx.reply(t("bot.session_reset_project_mismatch"));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
463
498
|
if (!currentSession) {
|
|
464
499
|
await ctx.reply(t("bot.creating_session"));
|
|
465
500
|
const { data: session, error } = await opencodeClient.session.create({
|
|
@@ -476,6 +511,7 @@ export function createBot() {
|
|
|
476
511
|
directory: currentProject.worktree,
|
|
477
512
|
};
|
|
478
513
|
setCurrentSession(currentSession);
|
|
514
|
+
await ingestSessionInfoForCache(session);
|
|
479
515
|
// Create pinned message for new session
|
|
480
516
|
try {
|
|
481
517
|
await pinnedMessageManager.onSessionChange(session.id, session.title);
|
|
@@ -504,6 +540,7 @@ export function createBot() {
|
|
|
504
540
|
}
|
|
505
541
|
}
|
|
506
542
|
}
|
|
543
|
+
await ensureEventSubscription(currentSession.directory);
|
|
507
544
|
summaryAggregator.setSession(currentSession.id);
|
|
508
545
|
summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
|
|
509
546
|
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
@@ -517,7 +554,7 @@ export function createBot() {
|
|
|
517
554
|
const storedModel = getStoredModel();
|
|
518
555
|
const promptOptions = {
|
|
519
556
|
sessionID: currentSession.id,
|
|
520
|
-
directory:
|
|
557
|
+
directory: currentSession.directory,
|
|
521
558
|
parts: [{ type: "text", text }],
|
|
522
559
|
agent: currentAgent,
|
|
523
560
|
};
|
|
@@ -542,18 +579,20 @@ export function createBot() {
|
|
|
542
579
|
task: () => opencodeClient.session.prompt(promptOptions),
|
|
543
580
|
onSuccess: ({ error }) => {
|
|
544
581
|
if (error) {
|
|
545
|
-
|
|
582
|
+
const details = formatErrorDetails(error);
|
|
583
|
+
logger.error("OpenCode API error:", error);
|
|
546
584
|
// Send the error via API directly because ctx is no longer available
|
|
547
585
|
void bot.api
|
|
548
586
|
.sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
|
|
549
|
-
details
|
|
587
|
+
details,
|
|
550
588
|
}))
|
|
551
589
|
.catch(() => { });
|
|
552
590
|
return;
|
|
553
591
|
}
|
|
554
592
|
logger.info("[Bot] session.prompt completed");
|
|
555
593
|
},
|
|
556
|
-
onError: () => {
|
|
594
|
+
onError: (error) => {
|
|
595
|
+
logger.error("[Bot] session.prompt background task failed:", error);
|
|
557
596
|
void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
|
|
558
597
|
},
|
|
559
598
|
});
|
package/dist/i18n/en.js
CHANGED
|
@@ -26,6 +26,7 @@ export const en = {
|
|
|
26
26
|
"bot.create_session_error": "🔴 Failed to create session. Try /new or check server status with /status.",
|
|
27
27
|
"bot.session_created": "✅ Session created: {title}",
|
|
28
28
|
"bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
|
|
29
|
+
"bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
|
|
29
30
|
"bot.prompt_send_error_detailed": "🔴 Failed to send request.\n\nDetails: {details}",
|
|
30
31
|
"bot.prompt_send_error": "🔴 An error occurred while sending request to OpenCode.",
|
|
31
32
|
"status.header_running": "🟢 **OpenCode Server is running**",
|
|
@@ -47,7 +48,7 @@ export const en = {
|
|
|
47
48
|
"status.session_not_selected": "📋 Current session: not selected",
|
|
48
49
|
"status.session_hint": "Use /sessions to select one or /new to create one",
|
|
49
50
|
"status.server_unavailable": "🔴 OpenCode Server is unavailable\n\nUse /opencode_start to start the server.",
|
|
50
|
-
"projects.empty": "📭 No projects found.",
|
|
51
|
+
"projects.empty": "📭 No projects found.\n\nOpen a directory in OpenCode and create at least one session, then it will appear here.",
|
|
51
52
|
"projects.select": "Select a project:",
|
|
52
53
|
"projects.select_with_current": "Select a project:\n\nCurrent: 🏗 {project}",
|
|
53
54
|
"projects.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading projects.",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -26,6 +26,7 @@ export const ru = {
|
|
|
26
26
|
"bot.create_session_error": "🔴 Не удалось создать сессию. Попробуйте команду /new или проверьте статус сервера /status.",
|
|
27
27
|
"bot.session_created": "✅ Сессия создана: {title}",
|
|
28
28
|
"bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
|
|
29
|
+
"bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
|
|
29
30
|
"bot.prompt_send_error_detailed": "🔴 Ошибка при отправке запроса.\n\nДетали: {details}",
|
|
30
31
|
"bot.prompt_send_error": "🔴 Произошла ошибка при отправке запроса в OpenCode.",
|
|
31
32
|
"status.header_running": "🟢 **OpenCode Server запущен**",
|
|
@@ -47,7 +48,7 @@ export const ru = {
|
|
|
47
48
|
"status.session_not_selected": "📋 Текущая сессия: не выбрана",
|
|
48
49
|
"status.session_hint": "Используйте /sessions для выбора или /new для создания",
|
|
49
50
|
"status.server_unavailable": "🔴 OpenCode Server недоступен\n\nИспользуйте /opencode_start для запуска сервера.",
|
|
50
|
-
"projects.empty": "📭 Проектов
|
|
51
|
+
"projects.empty": "📭 Проектов нет.\n\nОткройте директорию в OpenCode и создайте хотя бы одну сессию, после этого она появится здесь.",
|
|
51
52
|
"projects.select": "Выберите проект:",
|
|
52
53
|
"projects.select_with_current": "Выберите проект:\n\nТекущий: 🏗 {project}",
|
|
53
54
|
"projects.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка проектов.",
|
package/dist/opencode/events.js
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import { opencodeClient } from "./client.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
|
+
const RECONNECT_BASE_DELAY_MS = 1000;
|
|
4
|
+
const RECONNECT_MAX_DELAY_MS = 15000;
|
|
5
|
+
const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
|
|
3
6
|
let eventStream = null;
|
|
4
7
|
let eventCallback = null;
|
|
5
8
|
let isListening = false;
|
|
6
9
|
let activeDirectory = null;
|
|
7
10
|
let streamAbortController = null;
|
|
11
|
+
function getReconnectDelayMs(attempt) {
|
|
12
|
+
const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
13
|
+
return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
|
|
14
|
+
}
|
|
15
|
+
function waitWithAbort(ms, signal) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
resolve(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const onAbort = () => {
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
signal.removeEventListener("abort", onAbort);
|
|
24
|
+
resolve(false);
|
|
25
|
+
};
|
|
26
|
+
const timeout = setTimeout(() => {
|
|
27
|
+
signal.removeEventListener("abort", onAbort);
|
|
28
|
+
resolve(true);
|
|
29
|
+
}, ms);
|
|
30
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
8
33
|
export async function subscribeToEvents(directory, callback) {
|
|
9
34
|
if (isListening && activeDirectory === directory) {
|
|
35
|
+
eventCallback = callback;
|
|
10
36
|
logger.debug(`Event listener already running for ${directory}`);
|
|
11
37
|
return;
|
|
12
38
|
}
|
|
@@ -23,24 +49,59 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
23
49
|
isListening = true;
|
|
24
50
|
streamAbortController = controller;
|
|
25
51
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
let reconnectAttempt = 0;
|
|
53
|
+
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
|
|
56
|
+
if (!result.stream) {
|
|
57
|
+
throw new Error(FATAL_NO_STREAM_ERROR);
|
|
58
|
+
}
|
|
59
|
+
reconnectAttempt = 0;
|
|
60
|
+
eventStream = result.stream;
|
|
61
|
+
for await (const event of eventStream) {
|
|
62
|
+
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
63
|
+
logger.debug(`Event listener stopped or changed directory, breaking loop`);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
// CRITICAL: Explicitly yield to the event loop BEFORE processing the event
|
|
67
|
+
// This allows grammY to handle getUpdates between SSE events
|
|
68
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
69
|
+
if (eventCallback) {
|
|
70
|
+
// Use setImmediate to avoid blocking the event loop
|
|
71
|
+
// and let grammY process incoming Telegram updates
|
|
72
|
+
const callbackSnapshot = eventCallback;
|
|
73
|
+
setImmediate(() => callbackSnapshot(event));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
eventStream = null;
|
|
77
|
+
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
reconnectAttempt++;
|
|
81
|
+
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
82
|
+
logger.warn(`Event stream ended for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
83
|
+
const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
|
|
84
|
+
if (!shouldContinue) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
35
87
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
catch (error) {
|
|
89
|
+
eventStream = null;
|
|
90
|
+
if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
|
|
91
|
+
logger.info("Event listener aborted");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof Error && error.message === FATAL_NO_STREAM_ERROR) {
|
|
95
|
+
logger.error("Event stream fatal error:", error);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
reconnectAttempt++;
|
|
99
|
+
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
100
|
+
logger.error(`Event stream error for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`, error);
|
|
101
|
+
const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
|
|
102
|
+
if (!shouldContinue) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
44
105
|
}
|
|
45
106
|
}
|
|
46
107
|
}
|
package/dist/project/manager.js
CHANGED
|
@@ -1,14 +1,74 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { opencodeClient } from "../opencode/client.js";
|
|
4
|
+
import { getCachedSessionProjects } from "../session/cache-manager.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
async function isLinkedGitWorktree(worktree) {
|
|
7
|
+
if (worktree === "/") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const gitPath = path.join(worktree, ".git");
|
|
11
|
+
try {
|
|
12
|
+
const gitStat = await stat(gitPath);
|
|
13
|
+
if (!gitStat.isFile()) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const gitPointer = (await readFile(gitPath, "utf-8")).trim();
|
|
17
|
+
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
|
|
22
|
+
return gitDir.includes("/.git/worktrees/");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function worktreeKey(worktree) {
|
|
29
|
+
if (process.platform === "win32") {
|
|
30
|
+
return worktree.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
return worktree;
|
|
33
|
+
}
|
|
2
34
|
export async function getProjects() {
|
|
3
35
|
const { data: projects, error } = await opencodeClient.project.list();
|
|
4
36
|
if (error || !projects) {
|
|
5
37
|
throw error || new Error("No data received from server");
|
|
6
38
|
}
|
|
7
|
-
|
|
39
|
+
const apiProjects = projects.map((project) => ({
|
|
8
40
|
id: project.id,
|
|
9
41
|
worktree: project.worktree,
|
|
10
42
|
name: project.name || project.worktree,
|
|
43
|
+
lastUpdated: project.time?.updated ?? 0,
|
|
11
44
|
}));
|
|
45
|
+
const cachedProjects = await getCachedSessionProjects();
|
|
46
|
+
const mergedByWorktree = new Map();
|
|
47
|
+
for (const apiProject of apiProjects) {
|
|
48
|
+
mergedByWorktree.set(worktreeKey(apiProject.worktree), apiProject);
|
|
49
|
+
}
|
|
50
|
+
for (const cachedProject of cachedProjects) {
|
|
51
|
+
const key = worktreeKey(cachedProject.worktree);
|
|
52
|
+
const existing = mergedByWorktree.get(key);
|
|
53
|
+
if (existing) {
|
|
54
|
+
if ((cachedProject.lastUpdated ?? 0) > existing.lastUpdated) {
|
|
55
|
+
existing.lastUpdated = cachedProject.lastUpdated;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
mergedByWorktree.set(key, {
|
|
60
|
+
id: cachedProject.id,
|
|
61
|
+
worktree: cachedProject.worktree,
|
|
62
|
+
name: cachedProject.name,
|
|
63
|
+
lastUpdated: cachedProject.lastUpdated ?? 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const projectList = Array.from(mergedByWorktree.values()).sort((left, right) => right.lastUpdated - left.lastUpdated);
|
|
67
|
+
const linkedWorktreeFlags = await Promise.all(projectList.map((project) => isLinkedGitWorktree(project.worktree)));
|
|
68
|
+
const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
|
|
69
|
+
const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
|
|
70
|
+
logger.debug(`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`);
|
|
71
|
+
return visibleProjects.map(({ id, worktree, name }) => ({ id, worktree, name }));
|
|
12
72
|
}
|
|
13
73
|
export async function getProjectById(id) {
|
|
14
74
|
const projects = await getProjects();
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { opencodeClient } from "../opencode/client.js";
|
|
5
|
+
import { getSessionDirectoryCache, setSessionDirectoryCache } from "../settings/manager.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
const CACHE_VERSION = 1;
|
|
8
|
+
const INITIAL_WARMUP_LIMIT = 1000;
|
|
9
|
+
const INCREMENTAL_SYNC_LIMIT = 1000;
|
|
10
|
+
const MAX_CACHED_DIRECTORIES = 10;
|
|
11
|
+
const SYNC_SAFETY_WINDOW_MS = 60_000;
|
|
12
|
+
const SYNC_COOLDOWN_MS = 60_000;
|
|
13
|
+
const STORAGE_FALLBACK_SCAN_LIMIT = 200;
|
|
14
|
+
const SQLITE_FALLBACK_QUERY_LIMIT = 200;
|
|
15
|
+
const EMPTY_CACHE = {
|
|
16
|
+
version: CACHE_VERSION,
|
|
17
|
+
lastSyncedUpdatedAt: 0,
|
|
18
|
+
directories: [],
|
|
19
|
+
};
|
|
20
|
+
function createEmptyCacheData() {
|
|
21
|
+
return {
|
|
22
|
+
version: EMPTY_CACHE.version,
|
|
23
|
+
lastSyncedUpdatedAt: EMPTY_CACHE.lastSyncedUpdatedAt,
|
|
24
|
+
directories: [],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
let cacheData = createEmptyCacheData();
|
|
28
|
+
let cacheLoaded = false;
|
|
29
|
+
let syncInFlight = null;
|
|
30
|
+
let lastSyncAttemptAt = 0;
|
|
31
|
+
let persistQueue = Promise.resolve();
|
|
32
|
+
function worktreeKey(worktree) {
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
return worktree.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
return worktree;
|
|
37
|
+
}
|
|
38
|
+
function isValidWorktree(worktree) {
|
|
39
|
+
const trimmed = worktree.trim();
|
|
40
|
+
return trimmed.length > 0 && trimmed !== "/";
|
|
41
|
+
}
|
|
42
|
+
function normalizeCacheData(raw) {
|
|
43
|
+
if (!raw || typeof raw !== "object") {
|
|
44
|
+
return createEmptyCacheData();
|
|
45
|
+
}
|
|
46
|
+
const value = raw;
|
|
47
|
+
const lastSyncedUpdatedAt = typeof value.lastSyncedUpdatedAt === "number" && Number.isFinite(value.lastSyncedUpdatedAt)
|
|
48
|
+
? value.lastSyncedUpdatedAt
|
|
49
|
+
: 0;
|
|
50
|
+
const directories = Array.isArray(value.directories)
|
|
51
|
+
? value.directories
|
|
52
|
+
.filter((item) => Boolean(item) &&
|
|
53
|
+
typeof item === "object" &&
|
|
54
|
+
typeof item.worktree === "string" &&
|
|
55
|
+
typeof item.lastUpdated === "number")
|
|
56
|
+
.map((item) => ({
|
|
57
|
+
worktree: item.worktree.trim(),
|
|
58
|
+
lastUpdated: item.lastUpdated,
|
|
59
|
+
}))
|
|
60
|
+
.filter((item) => isValidWorktree(item.worktree))
|
|
61
|
+
: [];
|
|
62
|
+
const data = {
|
|
63
|
+
version: CACHE_VERSION,
|
|
64
|
+
lastSyncedUpdatedAt,
|
|
65
|
+
directories,
|
|
66
|
+
};
|
|
67
|
+
dedupeAndTrimDirectories(data);
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
function dedupeAndTrimDirectories(data) {
|
|
71
|
+
const unique = new Map();
|
|
72
|
+
for (const item of data.directories) {
|
|
73
|
+
const key = worktreeKey(item.worktree);
|
|
74
|
+
const existing = unique.get(key);
|
|
75
|
+
if (!existing || existing.lastUpdated < item.lastUpdated) {
|
|
76
|
+
unique.set(key, item);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
data.directories = Array.from(unique.values())
|
|
80
|
+
.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
81
|
+
.slice(0, MAX_CACHED_DIRECTORIES);
|
|
82
|
+
}
|
|
83
|
+
async function ensureCacheLoaded() {
|
|
84
|
+
if (cacheLoaded) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const storedCache = getSessionDirectoryCache();
|
|
88
|
+
cacheData = normalizeCacheData(storedCache);
|
|
89
|
+
cacheLoaded = true;
|
|
90
|
+
logger.debug(`[SessionCache] Loaded ${cacheData.directories.length} directories from settings.sessionDirectoryCache`);
|
|
91
|
+
}
|
|
92
|
+
function queuePersist() {
|
|
93
|
+
persistQueue = persistQueue
|
|
94
|
+
.catch(() => {
|
|
95
|
+
// Keep queue chain alive if previous write failed.
|
|
96
|
+
})
|
|
97
|
+
.then(async () => {
|
|
98
|
+
try {
|
|
99
|
+
await setSessionDirectoryCache(cacheData);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
logger.error("[SessionCache] Failed to persist sessions cache", error);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return persistQueue;
|
|
106
|
+
}
|
|
107
|
+
function upsertDirectory(worktree, lastUpdated) {
|
|
108
|
+
if (!isValidWorktree(worktree)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const normalizedWorktree = worktree.trim();
|
|
112
|
+
const key = worktreeKey(normalizedWorktree);
|
|
113
|
+
const existingIndex = cacheData.directories.findIndex((item) => worktreeKey(item.worktree) === key);
|
|
114
|
+
if (existingIndex >= 0) {
|
|
115
|
+
const existing = cacheData.directories[existingIndex];
|
|
116
|
+
if (existing.lastUpdated >= lastUpdated) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
cacheData.directories[existingIndex] = {
|
|
120
|
+
worktree: existing.worktree,
|
|
121
|
+
lastUpdated,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
cacheData.directories.push({
|
|
126
|
+
worktree: normalizedWorktree,
|
|
127
|
+
lastUpdated,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
dedupeAndTrimDirectories(cacheData);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function buildListParams() {
|
|
134
|
+
const hasWatermark = cacheData.lastSyncedUpdatedAt > 0;
|
|
135
|
+
if (!hasWatermark) {
|
|
136
|
+
return { limit: INITIAL_WARMUP_LIMIT };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
limit: INCREMENTAL_SYNC_LIMIT,
|
|
140
|
+
start: Math.max(0, cacheData.lastSyncedUpdatedAt - SYNC_SAFETY_WINDOW_MS),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function createVirtualProjectId(worktree) {
|
|
144
|
+
const hash = createHash("sha1").update(worktree).digest("hex").slice(0, 16);
|
|
145
|
+
return `dir_${hash}`;
|
|
146
|
+
}
|
|
147
|
+
async function runSync() {
|
|
148
|
+
await ensureCacheLoaded();
|
|
149
|
+
const params = buildListParams();
|
|
150
|
+
const { data: sessions, error } = await opencodeClient.session.list(params);
|
|
151
|
+
if (error || !sessions) {
|
|
152
|
+
throw error || new Error("No session list received from server");
|
|
153
|
+
}
|
|
154
|
+
let changed = false;
|
|
155
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
156
|
+
for (const session of sessions) {
|
|
157
|
+
const updatedAt = session.time?.updated ?? Date.now();
|
|
158
|
+
if (upsertDirectory(session.directory, updatedAt)) {
|
|
159
|
+
changed = true;
|
|
160
|
+
}
|
|
161
|
+
if (updatedAt > maxUpdated) {
|
|
162
|
+
maxUpdated = updatedAt;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
166
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
if (changed) {
|
|
170
|
+
await queuePersist();
|
|
171
|
+
}
|
|
172
|
+
logger.debug(`[SessionCache] Synced sessions: fetched=${sessions.length}, directories=${cacheData.directories.length}, lastSyncedUpdatedAt=${cacheData.lastSyncedUpdatedAt}`);
|
|
173
|
+
}
|
|
174
|
+
function getStorageRootCandidates(pathInfo) {
|
|
175
|
+
const candidates = new Set();
|
|
176
|
+
if (pathInfo.home) {
|
|
177
|
+
candidates.add(path.join(pathInfo.home, ".local", "share", "opencode"));
|
|
178
|
+
}
|
|
179
|
+
if (pathInfo.state) {
|
|
180
|
+
const normalizedState = pathInfo.state.replace(/[\\/]+$/, "");
|
|
181
|
+
const lowerState = normalizedState.toLowerCase();
|
|
182
|
+
const marker = `${path.sep}state${path.sep}opencode`;
|
|
183
|
+
const lowerMarker = marker.toLowerCase();
|
|
184
|
+
if (lowerState.endsWith(lowerMarker)) {
|
|
185
|
+
const prefix = normalizedState.slice(0, normalizedState.length - marker.length);
|
|
186
|
+
candidates.add(path.join(prefix, "share", "opencode"));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return Array.from(candidates);
|
|
190
|
+
}
|
|
191
|
+
function getPathApi() {
|
|
192
|
+
return opencodeClient.path;
|
|
193
|
+
}
|
|
194
|
+
async function getStorageRootsFromApi() {
|
|
195
|
+
const pathApi = getPathApi();
|
|
196
|
+
if (!pathApi?.get) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const { data: pathInfo, error } = await pathApi.get();
|
|
200
|
+
if (error || !pathInfo) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
return getStorageRootCandidates(pathInfo);
|
|
204
|
+
}
|
|
205
|
+
async function querySessionDirectoriesFromSqlite(dbPath) {
|
|
206
|
+
try {
|
|
207
|
+
const db = new Database(dbPath, {
|
|
208
|
+
readonly: true,
|
|
209
|
+
fileMustExist: true,
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
const rows = db
|
|
213
|
+
.prepare(`
|
|
214
|
+
SELECT directory, MAX(time_updated) AS updated
|
|
215
|
+
FROM session
|
|
216
|
+
GROUP BY directory
|
|
217
|
+
ORDER BY updated DESC
|
|
218
|
+
LIMIT ?
|
|
219
|
+
`)
|
|
220
|
+
.all(SQLITE_FALLBACK_QUERY_LIMIT);
|
|
221
|
+
return rows
|
|
222
|
+
.filter((item) => Boolean(item) && typeof item.directory === "string")
|
|
223
|
+
.map((item) => ({
|
|
224
|
+
worktree: item.directory,
|
|
225
|
+
lastUpdated: typeof item.updated === "number" && Number.isFinite(item.updated) ? item.updated : 0,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
db.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
logger.debug(`[SessionCache] Failed to read sqlite fallback at ${dbPath}`, error);
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
async function ingestFromSqliteSessionDatabase() {
|
|
238
|
+
await ensureCacheLoaded();
|
|
239
|
+
const fs = await import("node:fs/promises");
|
|
240
|
+
const roots = await getStorageRootsFromApi();
|
|
241
|
+
for (const root of roots) {
|
|
242
|
+
const dbPath = path.join(root, "opencode.db");
|
|
243
|
+
try {
|
|
244
|
+
await fs.access(dbPath);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const rows = await querySessionDirectoriesFromSqlite(dbPath);
|
|
250
|
+
if (!rows || rows.length === 0) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
let changed = false;
|
|
254
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
if (upsertDirectory(row.worktree, row.lastUpdated)) {
|
|
257
|
+
changed = true;
|
|
258
|
+
}
|
|
259
|
+
if (row.lastUpdated > maxUpdated) {
|
|
260
|
+
maxUpdated = row.lastUpdated;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
264
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
265
|
+
changed = true;
|
|
266
|
+
}
|
|
267
|
+
if (changed) {
|
|
268
|
+
await queuePersist();
|
|
269
|
+
}
|
|
270
|
+
logger.debug(`[SessionCache] SQLite fallback loaded: db=${dbPath}, rows=${rows.length}, directories=${cacheData.directories.length}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function ingestFromGlobalSessionStorage() {
|
|
275
|
+
await ensureCacheLoaded();
|
|
276
|
+
const fs = await import("node:fs/promises");
|
|
277
|
+
const candidates = await getStorageRootsFromApi();
|
|
278
|
+
for (const storageRoot of candidates) {
|
|
279
|
+
const globalDir = path.join(storageRoot, "storage", "session", "global");
|
|
280
|
+
try {
|
|
281
|
+
const entries = await fs.readdir(globalDir, { withFileTypes: true });
|
|
282
|
+
const sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
|
|
283
|
+
const withMtime = await Promise.all(sessionFiles.map(async (entry) => {
|
|
284
|
+
const fullPath = path.join(globalDir, entry.name);
|
|
285
|
+
const stat = await fs.stat(fullPath);
|
|
286
|
+
return { fullPath, mtimeMs: stat.mtimeMs };
|
|
287
|
+
}));
|
|
288
|
+
const sorted = withMtime
|
|
289
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
290
|
+
.slice(0, STORAGE_FALLBACK_SCAN_LIMIT);
|
|
291
|
+
let changed = false;
|
|
292
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
293
|
+
for (const file of sorted) {
|
|
294
|
+
try {
|
|
295
|
+
const raw = await fs.readFile(file.fullPath, "utf-8");
|
|
296
|
+
const session = JSON.parse(raw);
|
|
297
|
+
if (!session.directory) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const updated = session.time?.updated ?? Math.trunc(file.mtimeMs);
|
|
301
|
+
if (upsertDirectory(session.directory, updated)) {
|
|
302
|
+
changed = true;
|
|
303
|
+
}
|
|
304
|
+
if (updated > maxUpdated) {
|
|
305
|
+
maxUpdated = updated;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Ignore malformed session files.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
313
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
314
|
+
changed = true;
|
|
315
|
+
}
|
|
316
|
+
if (changed) {
|
|
317
|
+
await queuePersist();
|
|
318
|
+
}
|
|
319
|
+
logger.debug(`[SessionCache] Storage fallback loaded: root=${storageRoot}, scanned=${sorted.length}, directories=${cacheData.directories.length}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Try next candidate path.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export async function warmupSessionDirectoryCache() {
|
|
328
|
+
await syncSessionDirectoryCache({ force: true });
|
|
329
|
+
try {
|
|
330
|
+
await ingestFromSqliteSessionDatabase();
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
logger.warn("[SessionCache] Failed sqlite fallback warmup", error);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
await ingestFromGlobalSessionStorage();
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
logger.warn("[SessionCache] Failed storage fallback warmup", error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export async function syncSessionDirectoryCache(options) {
|
|
343
|
+
await ensureCacheLoaded();
|
|
344
|
+
if (!options?.force && Date.now() - lastSyncAttemptAt < SYNC_COOLDOWN_MS) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (syncInFlight) {
|
|
348
|
+
return syncInFlight;
|
|
349
|
+
}
|
|
350
|
+
syncInFlight = runSync()
|
|
351
|
+
.then(() => {
|
|
352
|
+
lastSyncAttemptAt = Date.now();
|
|
353
|
+
})
|
|
354
|
+
.catch((error) => {
|
|
355
|
+
logger.warn("[SessionCache] Failed to sync sessions cache", error);
|
|
356
|
+
lastSyncAttemptAt = 0;
|
|
357
|
+
})
|
|
358
|
+
.finally(() => {
|
|
359
|
+
syncInFlight = null;
|
|
360
|
+
});
|
|
361
|
+
return syncInFlight;
|
|
362
|
+
}
|
|
363
|
+
export async function getCachedSessionDirectories() {
|
|
364
|
+
await ensureCacheLoaded();
|
|
365
|
+
return cacheData.directories.map((item) => ({ ...item }));
|
|
366
|
+
}
|
|
367
|
+
export async function getCachedSessionProjects() {
|
|
368
|
+
const directories = await getCachedSessionDirectories();
|
|
369
|
+
return directories.map((item) => ({
|
|
370
|
+
id: createVirtualProjectId(item.worktree),
|
|
371
|
+
worktree: item.worktree,
|
|
372
|
+
name: item.worktree,
|
|
373
|
+
lastUpdated: item.lastUpdated,
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
export async function upsertSessionDirectory(worktree, lastUpdated = Date.now()) {
|
|
377
|
+
await ensureCacheLoaded();
|
|
378
|
+
if (!upsertDirectory(worktree, lastUpdated)) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (lastUpdated > cacheData.lastSyncedUpdatedAt) {
|
|
382
|
+
cacheData.lastSyncedUpdatedAt = lastUpdated;
|
|
383
|
+
}
|
|
384
|
+
await queuePersist();
|
|
385
|
+
}
|
|
386
|
+
export async function ingestSessionInfoForCache(session) {
|
|
387
|
+
const directory = session.directory;
|
|
388
|
+
if (!directory) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const updated = session.time?.updated ?? Date.now();
|
|
392
|
+
await upsertSessionDirectory(directory, updated);
|
|
393
|
+
}
|
|
394
|
+
export function __resetSessionDirectoryCacheForTests() {
|
|
395
|
+
cacheData = createEmptyCacheData();
|
|
396
|
+
cacheLoaded = false;
|
|
397
|
+
syncInFlight = null;
|
|
398
|
+
lastSyncAttemptAt = 0;
|
|
399
|
+
persistQueue = Promise.resolve();
|
|
400
|
+
}
|
package/dist/settings/manager.js
CHANGED
|
@@ -17,15 +17,24 @@ async function readSettingsFile() {
|
|
|
17
17
|
return {};
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
let settingsWriteQueue = Promise.resolve();
|
|
20
21
|
function writeSettingsFile(settings) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
settingsWriteQueue = settingsWriteQueue
|
|
23
|
+
.catch(() => {
|
|
24
|
+
// Keep write queue alive after failed writes.
|
|
25
|
+
})
|
|
26
|
+
.then(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const fs = await import("fs/promises");
|
|
29
|
+
const settingsFilePath = getSettingsFilePath();
|
|
30
|
+
await fs.mkdir(path.dirname(settingsFilePath), { recursive: true });
|
|
31
|
+
await fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
26
34
|
logger.error("[SettingsManager] Error writing settings file:", err);
|
|
27
|
-
}
|
|
35
|
+
}
|
|
28
36
|
});
|
|
37
|
+
return settingsWriteQueue;
|
|
29
38
|
}
|
|
30
39
|
let currentSettings = {};
|
|
31
40
|
export function getCurrentProject() {
|
|
@@ -33,66 +42,81 @@ export function getCurrentProject() {
|
|
|
33
42
|
}
|
|
34
43
|
export function setCurrentProject(projectInfo) {
|
|
35
44
|
currentSettings.currentProject = projectInfo;
|
|
36
|
-
writeSettingsFile(currentSettings);
|
|
45
|
+
void writeSettingsFile(currentSettings);
|
|
37
46
|
}
|
|
38
47
|
export function clearProject() {
|
|
39
48
|
currentSettings.currentProject = undefined;
|
|
40
|
-
writeSettingsFile(currentSettings);
|
|
49
|
+
void writeSettingsFile(currentSettings);
|
|
41
50
|
}
|
|
42
51
|
export function getCurrentSession() {
|
|
43
52
|
return currentSettings.currentSession;
|
|
44
53
|
}
|
|
45
54
|
export function setCurrentSession(sessionInfo) {
|
|
46
55
|
currentSettings.currentSession = sessionInfo;
|
|
47
|
-
writeSettingsFile(currentSettings);
|
|
56
|
+
void writeSettingsFile(currentSettings);
|
|
48
57
|
}
|
|
49
58
|
export function clearSession() {
|
|
50
59
|
currentSettings.currentSession = undefined;
|
|
51
|
-
writeSettingsFile(currentSettings);
|
|
60
|
+
void writeSettingsFile(currentSettings);
|
|
52
61
|
}
|
|
53
62
|
export function getCurrentAgent() {
|
|
54
63
|
return currentSettings.currentAgent;
|
|
55
64
|
}
|
|
56
65
|
export function setCurrentAgent(agentName) {
|
|
57
66
|
currentSettings.currentAgent = agentName;
|
|
58
|
-
writeSettingsFile(currentSettings);
|
|
67
|
+
void writeSettingsFile(currentSettings);
|
|
59
68
|
}
|
|
60
69
|
export function clearCurrentAgent() {
|
|
61
70
|
currentSettings.currentAgent = undefined;
|
|
62
|
-
writeSettingsFile(currentSettings);
|
|
71
|
+
void writeSettingsFile(currentSettings);
|
|
63
72
|
}
|
|
64
73
|
export function getCurrentModel() {
|
|
65
74
|
return currentSettings.currentModel;
|
|
66
75
|
}
|
|
67
76
|
export function setCurrentModel(modelInfo) {
|
|
68
77
|
currentSettings.currentModel = modelInfo;
|
|
69
|
-
writeSettingsFile(currentSettings);
|
|
78
|
+
void writeSettingsFile(currentSettings);
|
|
70
79
|
}
|
|
71
80
|
export function clearCurrentModel() {
|
|
72
81
|
currentSettings.currentModel = undefined;
|
|
73
|
-
writeSettingsFile(currentSettings);
|
|
82
|
+
void writeSettingsFile(currentSettings);
|
|
74
83
|
}
|
|
75
84
|
export function getPinnedMessageId() {
|
|
76
85
|
return currentSettings.pinnedMessageId;
|
|
77
86
|
}
|
|
78
87
|
export function setPinnedMessageId(messageId) {
|
|
79
88
|
currentSettings.pinnedMessageId = messageId;
|
|
80
|
-
writeSettingsFile(currentSettings);
|
|
89
|
+
void writeSettingsFile(currentSettings);
|
|
81
90
|
}
|
|
82
91
|
export function clearPinnedMessageId() {
|
|
83
92
|
currentSettings.pinnedMessageId = undefined;
|
|
84
|
-
writeSettingsFile(currentSettings);
|
|
93
|
+
void writeSettingsFile(currentSettings);
|
|
85
94
|
}
|
|
86
95
|
export function getServerProcess() {
|
|
87
96
|
return currentSettings.serverProcess;
|
|
88
97
|
}
|
|
89
98
|
export function setServerProcess(processInfo) {
|
|
90
99
|
currentSettings.serverProcess = processInfo;
|
|
91
|
-
writeSettingsFile(currentSettings);
|
|
100
|
+
void writeSettingsFile(currentSettings);
|
|
92
101
|
}
|
|
93
102
|
export function clearServerProcess() {
|
|
94
103
|
currentSettings.serverProcess = undefined;
|
|
95
|
-
writeSettingsFile(currentSettings);
|
|
104
|
+
void writeSettingsFile(currentSettings);
|
|
105
|
+
}
|
|
106
|
+
export function getSessionDirectoryCache() {
|
|
107
|
+
return currentSettings.sessionDirectoryCache;
|
|
108
|
+
}
|
|
109
|
+
export function setSessionDirectoryCache(cache) {
|
|
110
|
+
currentSettings.sessionDirectoryCache = cache;
|
|
111
|
+
return writeSettingsFile(currentSettings);
|
|
112
|
+
}
|
|
113
|
+
export function clearSessionDirectoryCache() {
|
|
114
|
+
currentSettings.sessionDirectoryCache = undefined;
|
|
115
|
+
void writeSettingsFile(currentSettings);
|
|
116
|
+
}
|
|
117
|
+
export function __resetSettingsForTests() {
|
|
118
|
+
currentSettings = {};
|
|
119
|
+
settingsWriteQueue = Promise.resolve();
|
|
96
120
|
}
|
|
97
121
|
export async function loadSettings() {
|
|
98
122
|
currentSettings = await readSettingsFile();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_MAX_ERROR_DETAILS_LENGTH = 1500;
|
|
2
|
+
function clipText(value, maxLength) {
|
|
3
|
+
if (value.length <= maxLength) {
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
7
|
+
}
|
|
8
|
+
export function formatErrorDetails(error, maxLength = DEFAULT_MAX_ERROR_DETAILS_LENGTH) {
|
|
9
|
+
let details = "";
|
|
10
|
+
if (error instanceof Error) {
|
|
11
|
+
details = error.stack ?? `${error.name}: ${error.message}`;
|
|
12
|
+
}
|
|
13
|
+
else if (typeof error === "string") {
|
|
14
|
+
details = error;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
try {
|
|
18
|
+
details = JSON.stringify(error, null, 2);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
details = String(error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const normalized = details.trim();
|
|
25
|
+
if (!normalized || normalized === "{}" || normalized === "[object Object]") {
|
|
26
|
+
return "unknown error";
|
|
27
|
+
}
|
|
28
|
+
return clipText(normalized, maxLength);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -53,10 +53,12 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@grammyjs/menu": "^1.3.1",
|
|
55
55
|
"@opencode-ai/sdk": "^1.1.21",
|
|
56
|
+
"better-sqlite3": "^12.6.2",
|
|
56
57
|
"dotenv": "^17.2.3",
|
|
57
58
|
"grammy": "^1.39.2"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
61
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
60
62
|
"@types/node": "^25.0.8",
|
|
61
63
|
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
62
64
|
"@typescript-eslint/parser": "^8.53.0",
|