@grinev/opencode-telegram-bot 0.1.1 → 0.2.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/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/index.js +16 -3
- package/dist/bot/message-patterns.js +3 -0
- package/dist/i18n/en.js +1 -1
- package/dist/i18n/ru.js +1 -1
- package/dist/project/manager.js +61 -1
- package/dist/session/cache-manager.js +400 -0
- package/dist/settings/manager.js +42 -18
- 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
|
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";
|
|
@@ -29,6 +30,7 @@ import { summaryAggregator } from "../summary/aggregator.js";
|
|
|
29
30
|
import { formatSummary, formatToolInfo } from "../summary/formatter.js";
|
|
30
31
|
import { opencodeClient } from "../opencode/client.js";
|
|
31
32
|
import { getCurrentSession, setCurrentSession } from "../session/manager.js";
|
|
33
|
+
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
|
|
32
34
|
import { getCurrentProject } from "../settings/manager.js";
|
|
33
35
|
import { getStoredAgent } from "../agent/manager.js";
|
|
34
36
|
import { getStoredModel } from "../model/manager.js";
|
|
@@ -256,6 +258,15 @@ async function ensureEventSubscription() {
|
|
|
256
258
|
});
|
|
257
259
|
logger.info(`[Bot] Subscribing to OpenCode events for project: ${currentProject.worktree}`);
|
|
258
260
|
subscribeToEvents(currentProject.worktree, (event) => {
|
|
261
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
262
|
+
const info = event.properties.info;
|
|
263
|
+
if (info?.directory) {
|
|
264
|
+
safeBackgroundTask({
|
|
265
|
+
taskName: `session.cache.${event.type}`,
|
|
266
|
+
task: () => ingestSessionInfoForCache(info),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
259
270
|
summaryAggregator.processEvent(event);
|
|
260
271
|
}).catch((err) => {
|
|
261
272
|
logger.error("Failed to subscribe to events:", err);
|
|
@@ -369,8 +380,8 @@ export function createBot() {
|
|
|
369
380
|
}
|
|
370
381
|
});
|
|
371
382
|
// Handle Reply Keyboard button press (model selector)
|
|
372
|
-
//
|
|
373
|
-
bot.hears(
|
|
383
|
+
// Model button text is produced by formatModelForButton() and always starts with "🤖 ".
|
|
384
|
+
bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
374
385
|
logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
|
|
375
386
|
try {
|
|
376
387
|
await showModelSelectionMenu(ctx);
|
|
@@ -392,7 +403,8 @@ export function createBot() {
|
|
|
392
403
|
}
|
|
393
404
|
});
|
|
394
405
|
// Handle Reply Keyboard button press (variant selector)
|
|
395
|
-
|
|
406
|
+
// Keep support for both legacy "💭" and current "💡" prefix.
|
|
407
|
+
bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
396
408
|
logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
|
|
397
409
|
try {
|
|
398
410
|
await showVariantSelectionMenu(ctx);
|
|
@@ -476,6 +488,7 @@ export function createBot() {
|
|
|
476
488
|
directory: currentProject.worktree,
|
|
477
489
|
};
|
|
478
490
|
setCurrentSession(currentSession);
|
|
491
|
+
await ingestSessionInfoForCache(session);
|
|
479
492
|
// Create pinned message for new session
|
|
480
493
|
try {
|
|
481
494
|
await pinnedMessageManager.onSessionChange(session.id, session.title);
|
package/dist/i18n/en.js
CHANGED
|
@@ -47,7 +47,7 @@ export const en = {
|
|
|
47
47
|
"status.session_not_selected": "📋 Current session: not selected",
|
|
48
48
|
"status.session_hint": "Use /sessions to select one or /new to create one",
|
|
49
49
|
"status.server_unavailable": "🔴 OpenCode Server is unavailable\n\nUse /opencode_start to start the server.",
|
|
50
|
-
"projects.empty": "📭 No projects found.",
|
|
50
|
+
"projects.empty": "📭 No projects found.\n\nOpen a directory in OpenCode and create at least one session, then it will appear here.",
|
|
51
51
|
"projects.select": "Select a project:",
|
|
52
52
|
"projects.select_with_current": "Select a project:\n\nCurrent: 🏗 {project}",
|
|
53
53
|
"projects.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading projects.",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -47,7 +47,7 @@ export const ru = {
|
|
|
47
47
|
"status.session_not_selected": "📋 Текущая сессия: не выбрана",
|
|
48
48
|
"status.session_hint": "Используйте /sessions для выбора или /new для создания",
|
|
49
49
|
"status.server_unavailable": "🔴 OpenCode Server недоступен\n\nИспользуйте /opencode_start для запуска сервера.",
|
|
50
|
-
"projects.empty": "📭 Проектов
|
|
50
|
+
"projects.empty": "📭 Проектов нет.\n\nОткройте директорию в OpenCode и создайте хотя бы одну сессию, после этого она появится здесь.",
|
|
51
51
|
"projects.select": "Выберите проект:",
|
|
52
52
|
"projects.select_with_current": "Выберите проект:\n\nТекущий: 🏗 {project}",
|
|
53
53
|
"projects.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка проектов.",
|
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();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grinev/opencode-telegram-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|