opencode-telegram-group-topics-bot 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,455 @@
|
|
|
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 SERVER_UNAVAILABLE_ERROR_MARKERS = [
|
|
16
|
+
"fetch failed",
|
|
17
|
+
"econnrefused",
|
|
18
|
+
"connection refused",
|
|
19
|
+
"connect refused",
|
|
20
|
+
];
|
|
21
|
+
const EMPTY_CACHE = {
|
|
22
|
+
version: CACHE_VERSION,
|
|
23
|
+
lastSyncedUpdatedAt: 0,
|
|
24
|
+
directories: [],
|
|
25
|
+
};
|
|
26
|
+
function createEmptyCacheData() {
|
|
27
|
+
return {
|
|
28
|
+
version: EMPTY_CACHE.version,
|
|
29
|
+
lastSyncedUpdatedAt: EMPTY_CACHE.lastSyncedUpdatedAt,
|
|
30
|
+
directories: [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
let cacheData = createEmptyCacheData();
|
|
34
|
+
let cacheLoaded = false;
|
|
35
|
+
let syncInFlight = null;
|
|
36
|
+
let lastSyncAttemptAt = 0;
|
|
37
|
+
let persistQueue = Promise.resolve();
|
|
38
|
+
function worktreeKey(worktree) {
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
return worktree.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
return worktree;
|
|
43
|
+
}
|
|
44
|
+
function isValidWorktree(worktree) {
|
|
45
|
+
const trimmed = worktree.trim();
|
|
46
|
+
return trimmed.length > 0 && trimmed !== "/";
|
|
47
|
+
}
|
|
48
|
+
function normalizeCacheData(raw) {
|
|
49
|
+
if (!raw || typeof raw !== "object") {
|
|
50
|
+
return createEmptyCacheData();
|
|
51
|
+
}
|
|
52
|
+
const value = raw;
|
|
53
|
+
const lastSyncedUpdatedAt = typeof value.lastSyncedUpdatedAt === "number" && Number.isFinite(value.lastSyncedUpdatedAt)
|
|
54
|
+
? value.lastSyncedUpdatedAt
|
|
55
|
+
: 0;
|
|
56
|
+
const directories = Array.isArray(value.directories)
|
|
57
|
+
? value.directories
|
|
58
|
+
.filter((item) => Boolean(item) &&
|
|
59
|
+
typeof item === "object" &&
|
|
60
|
+
typeof item.worktree === "string" &&
|
|
61
|
+
typeof item.lastUpdated === "number")
|
|
62
|
+
.map((item) => ({
|
|
63
|
+
worktree: item.worktree.trim(),
|
|
64
|
+
lastUpdated: item.lastUpdated,
|
|
65
|
+
}))
|
|
66
|
+
.filter((item) => isValidWorktree(item.worktree))
|
|
67
|
+
: [];
|
|
68
|
+
const data = {
|
|
69
|
+
version: CACHE_VERSION,
|
|
70
|
+
lastSyncedUpdatedAt,
|
|
71
|
+
directories,
|
|
72
|
+
};
|
|
73
|
+
dedupeAndTrimDirectories(data);
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
function dedupeAndTrimDirectories(data) {
|
|
77
|
+
const unique = new Map();
|
|
78
|
+
for (const item of data.directories) {
|
|
79
|
+
const key = worktreeKey(item.worktree);
|
|
80
|
+
const existing = unique.get(key);
|
|
81
|
+
if (!existing || existing.lastUpdated < item.lastUpdated) {
|
|
82
|
+
unique.set(key, item);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
data.directories = Array.from(unique.values())
|
|
86
|
+
.sort((a, b) => b.lastUpdated - a.lastUpdated)
|
|
87
|
+
.slice(0, MAX_CACHED_DIRECTORIES);
|
|
88
|
+
}
|
|
89
|
+
async function ensureCacheLoaded() {
|
|
90
|
+
if (cacheLoaded) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const storedCache = getSessionDirectoryCache();
|
|
94
|
+
cacheData = normalizeCacheData(storedCache);
|
|
95
|
+
cacheLoaded = true;
|
|
96
|
+
logger.debug(`[SessionCache] Loaded ${cacheData.directories.length} directories from settings.sessionDirectoryCache`);
|
|
97
|
+
}
|
|
98
|
+
function queuePersist() {
|
|
99
|
+
persistQueue = persistQueue
|
|
100
|
+
.catch(() => {
|
|
101
|
+
// Keep queue chain alive if previous write failed.
|
|
102
|
+
})
|
|
103
|
+
.then(async () => {
|
|
104
|
+
try {
|
|
105
|
+
await setSessionDirectoryCache(cacheData);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.error("[SessionCache] Failed to persist sessions cache", error);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return persistQueue;
|
|
112
|
+
}
|
|
113
|
+
function upsertDirectory(worktree, lastUpdated) {
|
|
114
|
+
if (!isValidWorktree(worktree)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const normalizedWorktree = worktree.trim();
|
|
118
|
+
const key = worktreeKey(normalizedWorktree);
|
|
119
|
+
const existingIndex = cacheData.directories.findIndex((item) => worktreeKey(item.worktree) === key);
|
|
120
|
+
if (existingIndex >= 0) {
|
|
121
|
+
const existing = cacheData.directories[existingIndex];
|
|
122
|
+
if (existing.lastUpdated >= lastUpdated) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
cacheData.directories[existingIndex] = {
|
|
126
|
+
worktree: existing.worktree,
|
|
127
|
+
lastUpdated,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
cacheData.directories.push({
|
|
132
|
+
worktree: normalizedWorktree,
|
|
133
|
+
lastUpdated,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
dedupeAndTrimDirectories(cacheData);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
function buildListParams() {
|
|
140
|
+
const hasWatermark = cacheData.lastSyncedUpdatedAt > 0;
|
|
141
|
+
if (!hasWatermark) {
|
|
142
|
+
return { limit: INITIAL_WARMUP_LIMIT };
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
limit: INCREMENTAL_SYNC_LIMIT,
|
|
146
|
+
start: Math.max(0, cacheData.lastSyncedUpdatedAt - SYNC_SAFETY_WINDOW_MS),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function createVirtualProjectId(worktree) {
|
|
150
|
+
const hash = createHash("sha1").update(worktree).digest("hex").slice(0, 16);
|
|
151
|
+
return `dir_${hash}`;
|
|
152
|
+
}
|
|
153
|
+
function hasServerUnavailableMarker(value) {
|
|
154
|
+
const lower = value.toLowerCase();
|
|
155
|
+
return SERVER_UNAVAILABLE_ERROR_MARKERS.some((marker) => lower.includes(marker));
|
|
156
|
+
}
|
|
157
|
+
function isServerUnavailableError(error) {
|
|
158
|
+
const queue = [error];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
while (queue.length > 0) {
|
|
161
|
+
const current = queue.pop();
|
|
162
|
+
if (!current || seen.has(current)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
seen.add(current);
|
|
166
|
+
if (typeof current === "string") {
|
|
167
|
+
if (hasServerUnavailableMarker(current)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (current instanceof Error) {
|
|
173
|
+
if (hasServerUnavailableMarker(`${current.name}: ${current.message}`)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const errorWithCause = current;
|
|
177
|
+
if (errorWithCause.cause) {
|
|
178
|
+
queue.push(errorWithCause.cause);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (typeof current === "object") {
|
|
183
|
+
const value = current;
|
|
184
|
+
if (typeof value.code === "string" && hasServerUnavailableMarker(value.code)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (typeof value.message === "string" && hasServerUnavailableMarker(value.message)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (value.cause) {
|
|
191
|
+
queue.push(value.cause);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
async function runSync() {
|
|
198
|
+
await ensureCacheLoaded();
|
|
199
|
+
const params = buildListParams();
|
|
200
|
+
const { data: sessions, error } = await opencodeClient.session.list(params);
|
|
201
|
+
if (error || !sessions) {
|
|
202
|
+
throw error || new Error("No session list received from server");
|
|
203
|
+
}
|
|
204
|
+
let changed = false;
|
|
205
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
206
|
+
for (const session of sessions) {
|
|
207
|
+
const updatedAt = session.time?.updated ?? Date.now();
|
|
208
|
+
if (upsertDirectory(session.directory, updatedAt)) {
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if (updatedAt > maxUpdated) {
|
|
212
|
+
maxUpdated = updatedAt;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
216
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
if (changed) {
|
|
220
|
+
await queuePersist();
|
|
221
|
+
}
|
|
222
|
+
logger.debug(`[SessionCache] Synced sessions: fetched=${sessions.length}, directories=${cacheData.directories.length}, lastSyncedUpdatedAt=${cacheData.lastSyncedUpdatedAt}`);
|
|
223
|
+
}
|
|
224
|
+
function getStorageRootCandidates(pathInfo) {
|
|
225
|
+
const candidates = new Set();
|
|
226
|
+
if (pathInfo.home) {
|
|
227
|
+
candidates.add(path.join(pathInfo.home, ".local", "share", "opencode"));
|
|
228
|
+
}
|
|
229
|
+
if (pathInfo.state) {
|
|
230
|
+
const normalizedState = pathInfo.state.replace(/[\\/]+$/, "");
|
|
231
|
+
const lowerState = normalizedState.toLowerCase();
|
|
232
|
+
const marker = `${path.sep}state${path.sep}opencode`;
|
|
233
|
+
const lowerMarker = marker.toLowerCase();
|
|
234
|
+
if (lowerState.endsWith(lowerMarker)) {
|
|
235
|
+
const prefix = normalizedState.slice(0, normalizedState.length - marker.length);
|
|
236
|
+
candidates.add(path.join(prefix, "share", "opencode"));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return Array.from(candidates);
|
|
240
|
+
}
|
|
241
|
+
function getPathApi() {
|
|
242
|
+
return opencodeClient.path;
|
|
243
|
+
}
|
|
244
|
+
async function getStorageRootsFromApi() {
|
|
245
|
+
const pathApi = getPathApi();
|
|
246
|
+
if (!pathApi?.get) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const { data: pathInfo, error } = await pathApi.get();
|
|
250
|
+
if (error || !pathInfo) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
return getStorageRootCandidates(pathInfo);
|
|
254
|
+
}
|
|
255
|
+
async function querySessionDirectoriesFromSqlite(dbPath) {
|
|
256
|
+
try {
|
|
257
|
+
const db = new Database(dbPath, {
|
|
258
|
+
readonly: true,
|
|
259
|
+
fileMustExist: true,
|
|
260
|
+
});
|
|
261
|
+
try {
|
|
262
|
+
const rows = db
|
|
263
|
+
.prepare(`
|
|
264
|
+
SELECT directory, MAX(time_updated) AS updated
|
|
265
|
+
FROM session
|
|
266
|
+
GROUP BY directory
|
|
267
|
+
ORDER BY updated DESC
|
|
268
|
+
LIMIT ?
|
|
269
|
+
`)
|
|
270
|
+
.all(SQLITE_FALLBACK_QUERY_LIMIT);
|
|
271
|
+
return rows
|
|
272
|
+
.filter((item) => Boolean(item) && typeof item.directory === "string")
|
|
273
|
+
.map((item) => ({
|
|
274
|
+
worktree: item.directory,
|
|
275
|
+
lastUpdated: typeof item.updated === "number" && Number.isFinite(item.updated) ? item.updated : 0,
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
db.close();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
logger.debug(`[SessionCache] Failed to read sqlite fallback at ${dbPath}`, error);
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
async function ingestFromSqliteSessionDatabase() {
|
|
288
|
+
await ensureCacheLoaded();
|
|
289
|
+
const fs = await import("node:fs/promises");
|
|
290
|
+
const roots = await getStorageRootsFromApi();
|
|
291
|
+
for (const root of roots) {
|
|
292
|
+
const dbPath = path.join(root, "opencode.db");
|
|
293
|
+
try {
|
|
294
|
+
await fs.access(dbPath);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const rows = await querySessionDirectoriesFromSqlite(dbPath);
|
|
300
|
+
if (!rows || rows.length === 0) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
let changed = false;
|
|
304
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
305
|
+
for (const row of rows) {
|
|
306
|
+
if (upsertDirectory(row.worktree, row.lastUpdated)) {
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
if (row.lastUpdated > maxUpdated) {
|
|
310
|
+
maxUpdated = row.lastUpdated;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
314
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
315
|
+
changed = true;
|
|
316
|
+
}
|
|
317
|
+
if (changed) {
|
|
318
|
+
await queuePersist();
|
|
319
|
+
}
|
|
320
|
+
logger.debug(`[SessionCache] SQLite fallback loaded: db=${dbPath}, rows=${rows.length}, directories=${cacheData.directories.length}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function ingestFromGlobalSessionStorage() {
|
|
325
|
+
await ensureCacheLoaded();
|
|
326
|
+
const fs = await import("node:fs/promises");
|
|
327
|
+
const candidates = await getStorageRootsFromApi();
|
|
328
|
+
for (const storageRoot of candidates) {
|
|
329
|
+
const globalDir = path.join(storageRoot, "storage", "session", "global");
|
|
330
|
+
try {
|
|
331
|
+
const entries = await fs.readdir(globalDir, { withFileTypes: true });
|
|
332
|
+
const sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
|
|
333
|
+
const withMtime = await Promise.all(sessionFiles.map(async (entry) => {
|
|
334
|
+
const fullPath = path.join(globalDir, entry.name);
|
|
335
|
+
const stat = await fs.stat(fullPath);
|
|
336
|
+
return { fullPath, mtimeMs: stat.mtimeMs };
|
|
337
|
+
}));
|
|
338
|
+
const sorted = withMtime
|
|
339
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
340
|
+
.slice(0, STORAGE_FALLBACK_SCAN_LIMIT);
|
|
341
|
+
let changed = false;
|
|
342
|
+
let maxUpdated = cacheData.lastSyncedUpdatedAt;
|
|
343
|
+
for (const file of sorted) {
|
|
344
|
+
try {
|
|
345
|
+
const raw = await fs.readFile(file.fullPath, "utf-8");
|
|
346
|
+
const session = JSON.parse(raw);
|
|
347
|
+
if (!session.directory) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const updated = session.time?.updated ?? Math.trunc(file.mtimeMs);
|
|
351
|
+
if (upsertDirectory(session.directory, updated)) {
|
|
352
|
+
changed = true;
|
|
353
|
+
}
|
|
354
|
+
if (updated > maxUpdated) {
|
|
355
|
+
maxUpdated = updated;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Ignore malformed session files.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
|
|
363
|
+
cacheData.lastSyncedUpdatedAt = maxUpdated;
|
|
364
|
+
changed = true;
|
|
365
|
+
}
|
|
366
|
+
if (changed) {
|
|
367
|
+
await queuePersist();
|
|
368
|
+
}
|
|
369
|
+
logger.debug(`[SessionCache] Storage fallback loaded: root=${storageRoot}, scanned=${sorted.length}, directories=${cacheData.directories.length}`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Try next candidate path.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export async function warmupSessionDirectoryCache() {
|
|
378
|
+
await syncSessionDirectoryCache({ force: true });
|
|
379
|
+
try {
|
|
380
|
+
await ingestFromSqliteSessionDatabase();
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
logger.warn("[SessionCache] Failed sqlite fallback warmup", error);
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await ingestFromGlobalSessionStorage();
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
logger.warn("[SessionCache] Failed storage fallback warmup", error);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export async function syncSessionDirectoryCache(options) {
|
|
393
|
+
await ensureCacheLoaded();
|
|
394
|
+
if (!options?.force && Date.now() - lastSyncAttemptAt < SYNC_COOLDOWN_MS) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (syncInFlight) {
|
|
398
|
+
return syncInFlight;
|
|
399
|
+
}
|
|
400
|
+
syncInFlight = runSync()
|
|
401
|
+
.then(() => {
|
|
402
|
+
lastSyncAttemptAt = Date.now();
|
|
403
|
+
})
|
|
404
|
+
.catch((error) => {
|
|
405
|
+
if (isServerUnavailableError(error)) {
|
|
406
|
+
logger.warn("[SessionCache] OpenCode server is not running. Start it with: opencode serve");
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
logger.warn("[SessionCache] Failed to sync sessions cache", error);
|
|
410
|
+
}
|
|
411
|
+
lastSyncAttemptAt = 0;
|
|
412
|
+
})
|
|
413
|
+
.finally(() => {
|
|
414
|
+
syncInFlight = null;
|
|
415
|
+
});
|
|
416
|
+
return syncInFlight;
|
|
417
|
+
}
|
|
418
|
+
export async function getCachedSessionDirectories() {
|
|
419
|
+
await ensureCacheLoaded();
|
|
420
|
+
return cacheData.directories.map((item) => ({ ...item }));
|
|
421
|
+
}
|
|
422
|
+
export async function getCachedSessionProjects() {
|
|
423
|
+
const directories = await getCachedSessionDirectories();
|
|
424
|
+
return directories.map((item) => ({
|
|
425
|
+
id: createVirtualProjectId(item.worktree),
|
|
426
|
+
worktree: item.worktree,
|
|
427
|
+
name: item.worktree,
|
|
428
|
+
lastUpdated: item.lastUpdated,
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
export async function upsertSessionDirectory(worktree, lastUpdated = Date.now()) {
|
|
432
|
+
await ensureCacheLoaded();
|
|
433
|
+
if (!upsertDirectory(worktree, lastUpdated)) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (lastUpdated > cacheData.lastSyncedUpdatedAt) {
|
|
437
|
+
cacheData.lastSyncedUpdatedAt = lastUpdated;
|
|
438
|
+
}
|
|
439
|
+
await queuePersist();
|
|
440
|
+
}
|
|
441
|
+
export async function ingestSessionInfoForCache(session) {
|
|
442
|
+
const directory = session.directory;
|
|
443
|
+
if (!directory) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const updated = session.time?.updated ?? Date.now();
|
|
447
|
+
await upsertSessionDirectory(directory, updated);
|
|
448
|
+
}
|
|
449
|
+
export function __resetSessionDirectoryCacheForTests() {
|
|
450
|
+
cacheData = createEmptyCacheData();
|
|
451
|
+
cacheLoaded = false;
|
|
452
|
+
syncInFlight = null;
|
|
453
|
+
lastSyncAttemptAt = 0;
|
|
454
|
+
persistQueue = Promise.resolve();
|
|
455
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getCurrentSession as getSettingsSession, getScopedSessions, setCurrentSession as setSettingsSession, clearSession as clearSettingsSession, } from "../settings/manager.js";
|
|
2
|
+
import { parseScopeKey, SCOPE_CONTEXT } from "../bot/scope.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
const GLOBAL_SCOPE_KEY = "global";
|
|
5
|
+
const sessionsByScope = new Map();
|
|
6
|
+
const scopeBySessionId = new Map();
|
|
7
|
+
let hydrated = false;
|
|
8
|
+
function isTopicScopeKey(scopeKey) {
|
|
9
|
+
return parseScopeKey(scopeKey)?.context === SCOPE_CONTEXT.GROUP_TOPIC;
|
|
10
|
+
}
|
|
11
|
+
function ensureSessionsLoaded() {
|
|
12
|
+
if (hydrated) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
hydrated = true;
|
|
16
|
+
const scopedSessions = getScopedSessions();
|
|
17
|
+
for (const [scopeKey, sessionInfo] of Object.entries(scopedSessions)) {
|
|
18
|
+
sessionsByScope.set(scopeKey, sessionInfo);
|
|
19
|
+
scopeBySessionId.set(sessionInfo.id, scopeKey);
|
|
20
|
+
}
|
|
21
|
+
const settingsSession = getSettingsSession();
|
|
22
|
+
if (!settingsSession) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
sessionsByScope.set(GLOBAL_SCOPE_KEY, settingsSession);
|
|
26
|
+
scopeBySessionId.set(settingsSession.id, GLOBAL_SCOPE_KEY);
|
|
27
|
+
}
|
|
28
|
+
export function setCurrentSession(sessionInfo, scopeKey = GLOBAL_SCOPE_KEY) {
|
|
29
|
+
ensureSessionsLoaded();
|
|
30
|
+
const previous = sessionsByScope.get(scopeKey);
|
|
31
|
+
if (isTopicScopeKey(scopeKey) && previous && previous.id !== sessionInfo.id) {
|
|
32
|
+
logger.warn(`[SessionManager] Rejecting session switch in immutable topic scope: scope=${scopeKey}, existing=${previous.id}, requested=${sessionInfo.id}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (previous && previous.id !== sessionInfo.id) {
|
|
36
|
+
scopeBySessionId.delete(previous.id);
|
|
37
|
+
}
|
|
38
|
+
const previousScopeForSession = scopeBySessionId.get(sessionInfo.id);
|
|
39
|
+
if (previousScopeForSession && previousScopeForSession !== scopeKey) {
|
|
40
|
+
sessionsByScope.delete(previousScopeForSession);
|
|
41
|
+
}
|
|
42
|
+
sessionsByScope.set(scopeKey, sessionInfo);
|
|
43
|
+
scopeBySessionId.set(sessionInfo.id, scopeKey);
|
|
44
|
+
if (scopeKey === GLOBAL_SCOPE_KEY) {
|
|
45
|
+
setSettingsSession(sessionInfo, scopeKey);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setSettingsSession(sessionInfo, scopeKey);
|
|
49
|
+
}
|
|
50
|
+
export function getCurrentSession(scopeKey = GLOBAL_SCOPE_KEY) {
|
|
51
|
+
ensureSessionsLoaded();
|
|
52
|
+
return sessionsByScope.get(scopeKey) ?? null;
|
|
53
|
+
}
|
|
54
|
+
export function clearSession(scopeKey = GLOBAL_SCOPE_KEY) {
|
|
55
|
+
ensureSessionsLoaded();
|
|
56
|
+
const session = sessionsByScope.get(scopeKey);
|
|
57
|
+
if (session) {
|
|
58
|
+
scopeBySessionId.delete(session.id);
|
|
59
|
+
}
|
|
60
|
+
sessionsByScope.delete(scopeKey);
|
|
61
|
+
if (scopeKey === GLOBAL_SCOPE_KEY) {
|
|
62
|
+
clearSettingsSession(scopeKey);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
clearSettingsSession(scopeKey);
|
|
66
|
+
}
|
|
67
|
+
export function getScopeForSession(sessionId) {
|
|
68
|
+
ensureSessionsLoaded();
|
|
69
|
+
return scopeBySessionId.get(sessionId) ?? null;
|
|
70
|
+
}
|
|
71
|
+
export function registerSessionScope(sessionId, scopeKey) {
|
|
72
|
+
ensureSessionsLoaded();
|
|
73
|
+
scopeBySessionId.set(sessionId, scopeKey);
|
|
74
|
+
const sessionInfo = sessionsByScope.get(scopeKey);
|
|
75
|
+
if (!sessionInfo || sessionInfo.id !== sessionId) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setSettingsSession(sessionInfo, scopeKey);
|
|
79
|
+
}
|
|
80
|
+
export function getSessionById(sessionId) {
|
|
81
|
+
ensureSessionsLoaded();
|
|
82
|
+
const scopeKey = scopeBySessionId.get(sessionId);
|
|
83
|
+
if (!scopeKey) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return sessionsByScope.get(scopeKey) ?? null;
|
|
87
|
+
}
|