@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.
@@ -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) {
@@ -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
- if (projects.length === 0) {
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
- projects.forEach((project, index) => {
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
- // Pattern: "ProviderName/ModelName" (may contain spaces, dots, parentheses, etc.)
373
- bot.hears(/^[A-Za-z0-9\-\. ]+\/[A-Za-z0-9\-\. ()]+\.{0,3}$/, async (ctx) => {
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
- bot.hears(/^💭 \w+$/, async (ctx) => {
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);
@@ -0,0 +1,3 @@
1
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s.+$/;
2
+ // Keep support for both legacy "💭" and current "💡" prefix.
3
+ export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
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 недоступен или произошла ошибка при получении списка проектов.",
@@ -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
- return projects.map((project) => ({
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
+ }
@@ -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
- import("fs/promises").then((fs) => {
22
- const settingsFilePath = getSettingsFilePath();
23
- fs.mkdir(path.dirname(settingsFilePath), { recursive: true })
24
- .then(() => fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)))
25
- .catch((err) => {
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.1.1",
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",